Multi-site Episerver Solutions using MVC Areas: Block Preview

on January 28, 2021 in Episerver, Multi-site, Architecture, Development

An important piece of the editing experience, which is often overlooked, is block preview. This feature is an efficient way for content editors to create and edit blocks that are meant to be reused across a site. Though, when used in the context of a multi-site solution, you could run into some issues that hinder those efficiencies.

This post is the forth in a series of posts related to architecting and developing multi-site Episerver solutions using MVC Areas. In this post, I'll cover how we handle block preview.

This post builds off the previous posts in this series. If you haven't read those post yet, you should do so before continuing with this one.

A Quick Overview

When we're talking about block preview, we're referring to the functionality that allows a content editor to edit and preview the rendering of a block before it's used on a page. If display options are configured for the site, it should also allow the editor to see how the block will be rendered for each option (where, typically, each display options represents the column width within a CSS grid system).

Block preview is not a new concept. It's been available as part of the AlloyDemoKit starter site since it was released over 5 years ago, it's part of the current Foundation starter site, and sample code is found in the core CMS documentation on Episerver World. So it's fairly trivial to copy and paste some sample code into your project and get basic block preview functionality working.

Block preview generally requires three components: a view model to transport the block preview content from the controller to the view, a controller to set up the view model and pass data to the view, and the actual view to render the content. Within those three components, you're primarily working with two properties: the ContentArea which the block will be edited within, and the actual block preview IContent; the block to be edited. The controller is treated as a rendering template for a common base class, usually BlockData, and using the TemplateDescriptor attribute ensures it is only used for on-page editing. The controller builds the view model and passes it off to the view, which inherits your site's layout and displays the block preview content with the same styling as the site.

The Multi-site Issue

That last part ("which inherits your site's layout") is the primary issue block preview in a multi-site solution, as the system doesn't know which site you're editing within. If you want to create block preview functionality in a multi-site solution, you would either (a) create all of the block preview components for each site, which introduces a significant amount of redundant code and work, or (b) create the block preview components once but use a generic layout template, which may not match the styles of the site where the block is being used. In my opinion, both options are not preferred.

The way to solve this would to have one centralized block preview feature, and have it use information about the current site that's being edited. Luckily for us, if you've followed the posts in this series from the beginning, we already have all the pieces we need to make this work. We just need to put them together.

Putting the Pieces Together

So to accomplish our goal, we'll still need those three components of block preview (view model, controller, and view), but we'll also pull in information from the current site that's being edited, so we'll need to update our StartPage information.

To start, our view model looks like this:

namespace MySolution.Business.Rendering.BlockPreview
{
    public class BlockPreviewModel
    {
        public BlockPreviewModel(IContent previewContent)
        {
            PreviewContent = previewContent;
            PreviewDisplayOptions = new List<PreviewDisplayOption>();
        }

        public IContent PreviewContent { get; set; }
        public List<PreviewDisplayOption> PreviewDisplayOptions { get; set; }

        public class PreviewDisplayOption
        {
            public string Name { get; set; }
            public string Tag { get; set; }
            public bool Supported { get; set; }
            public ContentArea ContentArea { get; set; }
        }
    }
}

You'll see we're building out block preview to work with our site's display options. Each display option will display the PreviewContent in the option's ContentArea at the proper column width for the site.

The controller is a little more involved, but as I mentioned, many of the concepts which are introduced in previous posts are utilized here:

namespace MySolution.Business.Rendering.BlockPreview
{
    [TemplateDescriptor(
        Inherited = true,
        TemplateTypeCategory = TemplateTypeCategories.MvcController,
        Tags = new[] { RenderingTags.Preview, RenderingTags.Edit },
        AvailableWithoutTag = false)]
    [VisitorGroupImpersonation]
    [RequireClientResources]
    public class BlockPreviewController : ActionControllerBase, IRenderTemplate<BlockData>
    {
        private readonly IContentLoader _contentLoader;
        private readonly DisplayOptions _displayOptions;
        private readonly TemplateResolver _templateResolver;

        private readonly IAreaStartPage _areaStartPage;

        public BlockPreviewController(
            IContentLoader contentLoader,
            DisplayOptions displayOptions,
            TemplateResolver templateResolver)
        {
            _contentLoader = contentLoader;
            _displayOptions = displayOptions;
            _templateResolver = templateResolver;

            _areaStartPage = _contentLoader.Get<IAreaStartPage>(SiteDefinition.Current.StartPage);
        }

        protected override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            base.OnActionExecuting(filterContext);

            filterContext.RouteData.DataTokens["area"] = _areaStartPage.AreaName;
        }

        public ActionResult Index(IContent currentContent)
        {
            var model = new BlockPreviewModel(currentContent);

            var displayOptions = _displayOptions.Select(option => new { option.Name, option.Tag, Supported = IsSupported(currentContent, option.Tag) });

            foreach (var displayOption in displayOptions)
            {
                var contentArea = new ContentArea();

                contentArea.Items.Add(new ContentAreaItem { ContentLink = currentContent.ContentLink });

                var previewDisplayOption = new BlockPreviewModel.PreviewDisplayOption
                {
                    Name = displayOption.Name,
                    Tag = displayOption.Tag,
                    Supported = displayOption.Supported,
                    ContentArea = contentArea
                };

                model.PreviewDisplayOptions.Add(previewDisplayOption);
                
            }
            
            return View("BlockPreview", _areaStartPage.AreaLayout, model);
        }

        private bool IsSupported(IContent content, string tag)
        {
            var templateModel = _templateResolver.Resolve(HttpContext, content.GetOriginalType(), content, TemplateTypeCategories.MvcPartial, tag);

            return templateModel != null;
        }
    }
}

There's a lot going on with this controller, but let me point out the important parts:

  • We're loading up the site's StartPage in the constructor, which hold the AreaName and AreaLayout properties.
  • We're adding the area data token to the RouteData, as we've done before, in the OnActionExecuting() filter method.
  • We're returning the View() action result, but providing the supplied layout path, instead of letting the _ViewStart.cshtml control it.

This means we'll need to make some updates to IAreaStartPage:

namespace MySolution.Business.Models
{
    public interface IAreaStartPage : IContentData
    {
        string AreaName { get; }
        string AreaLayout { get; }
    }
}

And to each site's StartPage:

namespace MySolution.Areas.SiteA.Models.Pages
{
    [ContentType(DisplayName = "Site A Start Page", GUID = "00000000-0000-0000-0000-000000000000",
        GroupName = SiteAConstants.AreaName)]
    public class StartPage : SiteABasePageData, IAreaStartPage
    {
        [Ignore]
        public string AreaName => SiteAConstants.AreaName;

        [Ignore]
        public string AreaLayout => "~/Areas/SiteA/Views/Shared/_Layout.cshtml";
    }
}

Once again, we are using the Ignore attribute to the AreaLayout property because Episerver doesn't really need to know about this property.

Then finally, we can create our view, which should be located at ~/Views/Shared/BlockPreview.cshtml:

@model MySolution.Business.Rendering.BlockPreview.BlockPreviewModel

<div class="container">

    @if (Model.PreviewDisplayOptions.Any())
    {
        foreach (var option in Model.PreviewDisplayOptions)
        {
            if (option.Supported)
            {
                <div class="alert alert-info">
                    @option.Name
                </div>
                <div class="row">
                    @Html.PropertyFor(x => option.ContentArea, new { Tag = option.Tag })
                </div>
            }
            else
            {
                <div class="alert alert-danger">
                    Display option not supported: @option.Name
                </div>
            }
        }
    }
    else
    {
        <div class="alert alert-danger">
            No display options available!
        </div>
    }

</div>

From here, nothing more needs to be done. When you select to edit a block, the block preview controller will take over, build the view model, and pass everything over to the view. Since we supplied which layout to use, the view will be rendered within the site which is currently being edited.

What's Next?

I have one more post in the series, in which I'll cover the usage of AllowedContentTypes, and address how we can clean up the "New Page" and "New Block" screen in the edit UI.

More posts in this series: