Creating a Custom Layout Element in Orchard

Let’s face it the new layouts / dynamic forms feature that was released with Orchard 1.9 is pretty awesome! It allows users to easily create page layouts using grids and add elements to these grids by simply dragging and dropping. It works great with all the CSS grid frameworks that are out there with minimal effort (including my favorite, Bootstrap) .

Out of the box there are plenty of elements including things like HTML, Text, and Media, but what if you need something more than that? Well, for something not out of the box, you will need to write a custom element. This tutorial will show you how to do that by creating an element that pulls the latest posts from Reddit.

This tutorial assumes that you have your orchard development environment already setup, but have never created a module before. If you have not set up your development environment check out part 2 of the creating a webshop module tutorial written by the same guys that created the layouts feature (Sipke Schoorstra and Daniel Stolt).

Creating a Module

Now, let’s get started with our custom element. First, we will need a module to hold our code. A module is an ASP.NET MVC area class library project that allows us to extend / modify the way Orchard functions. We could create this project manually by adding a few files, folders, references, and etc, but Orchard comes with a way that makes it much easier than that.

Open File Explorer and navigate to <your orchard directory>\src\Orchard.Web\bin and execute the file Orchard.exe.

Enable the Orchard Code Generation module that comes with the core of Orchard by executing the following command.
feature enable Orchard.CodeGeneration

Next create the module by executing the following command.
codegen module Ouwinga.Tutorials

Note: You can change Ouwinga.Tutorials to whatever namespace you would like. For instance something like MyCompany.RedditElement might be more appropriate, but I will be using this same module for multiple tutorials.

Tip: You can create a module that has multiple features that can be enabled / disabled independently of each other in the admin interface. See this guide for more information.

Now open the Orchard solution in Visual Studio and you will see our newly created module project has been added to the solution in the Modules folder. If you already had the solution open you will see the following prompt pop up since the codegen module automatically adds our module to the solution unless we add the /IncludeInSolution:false switch to the end of our module creation command. Just click the Reload button.

Configure Module Settings

Next we need to modify the default module settings to something that makes a little more sense than what got generated automatically. Open up the Module.txt manifest file in our newly created module project and change it to something more fitting. Below is what I changed mine to.

Name: Reddit Element
Category: Layout
AntiForgery: enabled
Author: David Ouwinga
Website: http://www.davidouwinga.com
Version: 1.0
OrchardVersion: 1.0
Description: This module adds a Reddit recent post element to layouts
Features:
    Ouwinga.Tutorials:
        Description: Adds a Reddit element to layouts
        Dependencies: Orchard.Layouts

Notice how I added Orchard.Layouts as a dependency of the Reddit Element feature. This makes the Layouts module required to be enabled before our feature can be enabled. Orchard will automatically enable it with our feature if it is not already enabled. I also added the category option and set it to Layout so that this module will show in the Layout category in the admin interface. You can find more information about the Module.txt manifest file in the official Orchard documentation.

Enabling the Module

Next we will enable the module. We could enable the module through the command line like we did for the Code Generation module, but I prefer to do it via the Orchard admin interface. Fire up your web server (or just debug from Visual Studio) if you haven’t already and browse to the admin dashboard (http://<your url>/admin). Go to the modules page by clicking Modules in the left side menu.

On the modules page, scroll down to the Layout category (which we set in our Module.txt file). Find the Reddit Element and click the Enable link.

Creating Our Element

Now that our module is enabled, lets go ahead and start writing the code to make it actually do something. One thing to note before we begin is that I am going to try to keep this as simple as possible since this is just a tutorial. Therefore, the element may not have all the “bells and whistles” you might expect from an element of this nature.

Let’s start by creating a new folder in our module called “Elements”. In this new Elements folder, we will add a new class file called “Reddit.cs”.

Below is the code for Reddit.cs:

using Orchard.Layouts.Framework.Elements;
using Orchard.Layouts.Helpers;

namespace Ouwinga.Tutorials.Elements
{
    public class Reddit : Element
    {
        public override string Category
        {
            get { return "Content"; }
        }

        public override bool HasEditor
        {
            get { return true; }
        }

        public string Subreddit
        {
            get { return this.Retrieve(x => x.Subreddit); }
            set { this.Store(x => x.Subreddit, value); }
        }

        public int CacheMinutes
        {
            get { return this.Retrieve(x => x.CacheMinutes); }
            set { this.Store(x => x.CacheMinutes, value); }
        }
    }
}

The Reddit class file inherits from “Element” which is found in the Orchard.Layouts namespace. Because of this, add a reference to Orchard.Layouts.

We override two properties from Element. The Category and HasEditor properties. The Category property is the toolbox category our element should be listed in, and HasEditor being set to true means we have editable settings for this element and an editor window should popup when it is placed or edited.

We also added an additional string property called Subreddit and an int property called CacheMinutes. These are going to be editable settings for our element so we set and get their values using the Retrieve and Store helpers from Orchard.Layouts.Helpers.

Note: Check out some of the other properties of Element that we can override  (check the source). A couple to note are DisplayText and Description. DisplayText is the name of our element that will display in the layout toolbox. By default this is the name of our class, so we did not need to change it for this element. Description is what shows under the name of the element in the layout toolbox. It allows you to describe your element a little bit and could come in handy to explain your element to your end users.

Creating the Reddit Service

Next, we are going to create a service to talk to the Reddit api that our driver (which we will create shortly) can use to get the recent posts.

Before we create our service lets create a few models that our service is going to need. In our Models folder create a class file named RedditJson.cs and a class file named RedditPost.cs.

Here is the code for RedditJson.cs:

using Newtonsoft.Json;

namespace Ouwinga.Tutorials.Models
{
    public class RedditJson
    {
        [JsonProperty("data")]
        public RedditDataJson Data { get; set; }
    }

    public class RedditDataJson
    {
        [JsonProperty("children")]
        public RedditPostJson[] Children { get; set; }
    }

    public class RedditPostJson
    {
        [JsonProperty("data")]
        public RedditPostDataJson Data { get; set; }
    }

    public class RedditPostDataJson
    {
        [JsonProperty("thumbnail")]
        public string ThumbnailUrl { get; set; }
        [JsonProperty("url")]
        public string LinkUrl { get; set; }
        [JsonProperty("title")]
        public string Title { get; set; }
    }
}

The RedditJson model is what we use to deserialize the JSON we retrieve from the Reddit API. There are a ton more properties we could have added to this JSON model that the Reddit API spits out, but to keep things simple we are only grabbing what is absolutely necessary for what we are trying to achieve.

Here is the code for RedditPost.cs:

namespace Ouwinga.Tutorials.Models
{
    public class RedditPost
    {
        public string Title { get; set; }
        public string LinkUrl { get; set; }
        public string ThumbnailUrl { get; set; }
    }
}

The RedditPost model is what we will convert the RedditJson model into. Doing the conversion is not 100% necessary, but to me it makes working with the data much easier and the RedditJson model doesn’t end up being used for more than its’ intended purpose of JSON deserialization.

With our models out of the way, let’s create a folder called Services. In this folder we need to create an interface for our service called IRedditService.cs and a class for the implementation of the interface called RedditService.cs.

Here is what our code for IRedditService.cs looks like:

using Orchard;
using Ouwinga.Tutorials.Models;
using System.Collections.Generic;

namespace Ouwinga.Tutorials.Services
{
    public interface IRedditService : IDependency
    {
        IEnumerable<RedditPost> GetPosts(string subreddit, int cacheMinutes);
    }
}

IRedditService inherits from IDependency so that it becomes an injectable Orchard dependency.

And here is what our code looks like for RedditService.cs:

using Newtonsoft.Json;
using Orchard.Caching;
using Orchard.Services;
using Ouwinga.Tutorials.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;

namespace Ouwinga.Tutorials.Services
{
    public class RedditService : IRedditService
    {
        protected readonly string _cacheKeyPrefix = "C4B4FC42-56F3-43AB-972F-1ADF0F271D01_";
        private readonly ICacheManager _cacheManager;
        private readonly IClock _clock;

        public RedditService(ICacheManager cacheManager, IClock clock)
        {
            _cacheManager = cacheManager;
            _clock = clock;
        }

        public IEnumerable<RedditPost> GetPosts(string subreddit, int cacheMinutes)
        {
            var cacheKey = _cacheKeyPrefix + subreddit;

            return _cacheManager.Get(cacheKey, ctx =>
            {
                ctx.Monitor(_clock.When(TimeSpan.FromMinutes(cacheMinutes)));
                return RetrieveRedditPosts(subreddit);
            });
        }

        private IEnumerable<RedditPost> RetrieveRedditPosts(string subreddit)
        {
            // Default to grabbing frontpage posts
            var apiUrl = "https://www.reddit.com/.json";

            if (!string.IsNullOrWhiteSpace(subreddit))
            {
                apiUrl = "https://www.reddit.com/r/" + subreddit + "/.json";
            }

            var request = (HttpWebRequest)WebRequest.Create(apiUrl);
            request.Method = "GET";

            var response = request.GetResponse();

            var responseStream = response.GetResponseStream();

            if (responseStream == null)
            {
                return null;
            }

            using (var reader = new StreamReader(responseStream))
            {
                var redditJson = JsonConvert.DeserializeObject<RedditJson>(reader.ReadToEnd());

                responseStream.Close();

                return RedditJsonToRedditPosts(redditJson);
            }
        }

        private IEnumerable<RedditPost> RedditJsonToRedditPosts(RedditJson redditJson)
        {
            var redditPosts = new List<RedditPost>();

            if (redditJson == null || redditJson.Data == null)
            {
                return null;
            }

            foreach (var child in redditJson.Data.Children)
            {
                if (child.Data == null)
                {
                    continue;
                }

                redditPosts.Add(new RedditPost
                {
                    ThumbnailUrl = child.Data.ThumbnailUrl,
                    LinkUrl = child.Data.LinkUrl,
                    Title = child.Data.Title
                });
            }

            return redditPosts;
        }
    }
}

I am not going to go into much detail on this service since there isn’t much going on that is related to Orchard and elements in particular. However, I will point out that we are using the cachemanager to cache the retrieved posts for the length of time specified in the element settings with the following code.

var cacheKey = _cacheKeyPrefix + subreddit;

return _cacheManager.Get(cacheKey, ctx =>
{
    ctx.Monitor(_clock.When(TimeSpan.FromMinutes(cacheMinutes)));
    return RetrieveRedditPosts(subreddit);
});

What we are doing here is setting up a unique key to reference our cache and then telling the service to only retrieve the posts from Reddit if there is nothing cached yet or the amount of minutes in the cacheMinutes setting have elapsed. Both clock and cacheManager were injected in the constructor of the service using Orchard’s built in dependency injection (using the IoC container called Autofac).

Creating an Element Driver

Our service is now ready to go, so let’s move on and create a driver for our element.

Before we actually create our driver we will need an editor view model that our driver can use for passing our two element settings to our editor view. Create a folder in the root of our module project called ViewModels. In this new folder create a class file called RedditEditorViewModel.cs.

Here is the code for RedditEditorViewModel.cs:

namespace Ouwinga.Tutorials.ViewModels
{
    public class RedditEditorViewModel
    {
        public string Subreddit { get; set; }
        public int CacheMinutes { get; set; }
    }
}

With our view model created, we need a folder for holding our driver, so create a folder called Drivers. In this folder create a class called RedditElementDriver.cs.

A driver in Orchard defines associations of shapes to display for each context that our element can render (yes, I stole this line from the official documentation ;p). In our case our driver needs to handle displaying / updating of the editor settings and displaying the frontend view. If you have created a content part driver before, you may find element drivers a bit simpler as some things are now handled for you.

Here is the code for RedditElementDriver.cs:

using Orchard.Layouts.Framework.Display;
using Orchard.Layouts.Framework.Drivers;
using Ouwinga.Tutorials.Elements;
using Ouwinga.Tutorials.Services;
using Ouwinga.Tutorials.ViewModels;
using System.Linq;

namespace Ouwinga.Tutorials.Drivers
{
    public class RedditElementDriver : ElementDriver<Reddit>
    {
        private readonly IRedditService _redditService;

        public RedditElementDriver(IRedditService redditService)
        {
            _redditService = redditService;
        }

        protected override EditorResult OnBuildEditor(Reddit element, ElementEditorContext context)
        {
            var viewModel = new RedditEditorViewModel
            {
                Subreddit = element.Subreddit,
                CacheMinutes = element.CacheMinutes
            };

            var editor = context.ShapeFactory.EditorTemplate(TemplateName: "Elements.Reddit", Model: viewModel);

            if (context.Updater != null)
            {
                context.Updater.TryUpdateModel(viewModel, context.Prefix, null, null);
                element.Subreddit = viewModel.Subreddit;
                element.CacheMinutes = viewModel.CacheMinutes;
            }

            return Editor(context, editor);
        }

        protected override void OnDisplaying(Reddit element, ElementDisplayContext context)
        {
            context.ElementShape.Posts = _redditService.GetPosts(element.Subreddit, element.CacheMinutes).ToList();
        }
    }
}

The driver class inherits from ElementDriver<TElement> and we inject our new Reddit service in the driver’s constructor. Next, we override the OnBuilderEditor method of ElementDriver<TElement> which takes our element and context as parameters which Orchard will pass in automatically. Using our element’s settings properties, we create an editor view model. After we have our view model created we create our editor shape using the ShapeFactory of our context and pass in our viewModel that we just created and we give the template a name which will be used when creating our editor view. Next, we check if OnBuildEditor is being called during an update (after the save button has been clicked in the editor settings window). If it is during an update we update our element’s settings in the database.

The OnDisplaying method override takes in our element and context as parameters again. In this method we add the posts property to our element’s shape. The posts property is filled with the posts that we get from our Reddit service. Being able to add properties to the shape this way makes it really easy to add properties beyond just what is in the element model.

Creating Element Views

Great, now we have all of our backend code finished, but we have nothing showing yet. For that we need to create the views for our element. To start, we will create our element’s edit view so that we can set our element settings when we add / edit our element. To do this we need to create a view file where Orchard expects it and with the correct name so that it can associate the view to our element. Start by creating a folder called EditorTemplates inside the Views folder of our module. Since we created our editor shape with the template name of Elements.Reddit our view file will use that name. Therefore, lets create a new view file in our new EditorTemplates folder called Elements.Reddit.cshtml.

Here is the code for Elements.Reddit.cshtml:

@model Ouwinga.Tutorials.ViewModels.RedditEditorViewModel

<fieldset>
<div>
        @Html.LabelFor(m => m.Subreddit, T("Subreddit"))
        @Html.TextBoxFor(m => m.Subreddit, new { @class = "text medium" })
        <span class="hint">@T("The subreddit to get posts from. Leave blank to use the frontpage of Reddit.")</span>
</div>
<div>
        @Html.LabelFor(m => m.CacheMinutes, T("Cache Minutes"))
        @Html.TextBoxFor(m => m.CacheMinutes, new { @class = "text", @type = "number" })
        <span class="hint">@T("The amount of time in minutes to cache the Reddit Posts. Leave blank or set to zero to disable caching.")</span>
</div>
</fieldset>

All we need for our element’s editor view is two text box inputs for the subreddit and cacheminute settings. Orchard will handle creating the save button for us, so we do not need to worry about that. The T() method is used for our label and hint span text for localization as the method translates the string based on the site’s default culture setting.

Next we need the view that is shown on the frontend of our site. Start by creating a folder called Elements inside the Views folder. Orchard will automatically look in this folder for a view file, but this time it just needs to be the same name as our element class name. In our case, we need to create a new view file in our new Elements folder called Reddit.cshtml.

Here is the code for Reddit.cshtml:

@using Ouwinga.Tutorials.Elements
@using Ouwinga.Tutorials.Models
@{
    var element = (Reddit)Model.Element;
    var subreddit = string.IsNullOrWhiteSpace(element.Subreddit) ? "Frontpage" : "/r/" + element.Subreddit.ToLower();
    var title = "Reddit Posts from " + subreddit;

    var posts = (List<RedditPost>) Model.Posts;
}

<section>
    <h1>@title</h1>
    @foreach (var post in posts) {
        <article class="row">
            @if (!string.IsNullOrEmpty(post.ThumbnailUrl) && post.ThumbnailUrl != "self" && post.ThumbnailUrl != "default") {
                <div class="span-2 cell">
                    <a href="@post.LinkUrl" target="_blank">
                        <img src="@post.ThumbnailUrl" alt="Thumbnail"/>
                    </a>
                </div>
            }
            <header class="span-10 cell">
                <h2><a href="@post.LinkUrl" target="_blank">@post.Title</a></h2>
            </header>
        </article>
    }
</section>

Tip: If we wanted a separate view for our element for when it is displayed in the layout editor vs the frontend of our site, we could create another view file with .Design at the end of its’ name. For instance, we could create a file called Reddit.Design.html in the Views/Elements folder to change what the element looks like in the layout editor.

This view sets a title for our element using the Subreddit setting set in the editor and then loops through all of our posts that we passed into the shape from our drivers OnDisplaying method.

Using Our Custom Element

Our element is now complete, so now it is time to add it to one of our pages. Go ahead and pick a page to add the element and drag and drop it into a column (or another container element such as the canvas). The following editor window will popup and you can enter your desired settings and click save.

Go ahead and publish the page and check it out on the frontend. You will see something like this.

It may not be pretty, but it gets the job done. If this is an element you actually wish to use you will want to actually style the element and likely implemented some pagination for getting more than just the first page of posts.

Conclusion

As you can see the new Layouts feature is really powerful and creating layout elements is actually quite easy. We didn’t even need to create any tables in a migration or anything like that. I am really excited to see the elements people come up with and release on the Orchard Gallery. If you have any questions or comments, please leave a comment below.

The source code for the completed module is available on GitHub here.