Features folder in Optimizely 12

Jevgenijs Pucko 31.01.2022 19:37:00

In this blog:

  • How to get rid of a custom view engine and implement dynamic approach via IViewLocationExpander;
  • How to get rid of: Block, Page suffix part in view path name to organize clear namespace;
  • How to organize block components in feature folder;

The migrated project used the feature folder structure. Many of us are familiar with this approach and use it to organize app by feature, and it looks like this:


Screenshot 2022-01-19 143019.png

To use this approach previously used a custom view engine that iterates over the contents of the Features folder and generates all possible view paths. This method is inefficient and can now be done dynamically with help of IViewLocationExpander.

First of all, you need to create the ControllerFeatureConvention class:

public class ControllerFeatureConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        var name = DeriveFeatureFolderName(controller);
        controller.Properties.Add("feature", name);
    }

    private string DeriveFeatureFolderName(ControllerModel model)
    {
        var @namespace = model.ControllerType.Namespace;
        var result = @namespace.Split('.')
            .SkipWhile(s => s != "Features")
            .Aggregate("", Path.Combine);

        return result;
    }
}

This class creates a feature using a controller, for example, if we have the feature Blog and it contains BlogController inside, the output will be Features/Blog.

Unfortunately, not all features contain a controller inside and they use some DefaultPageController for this situation, we can't get by with the ControllerFeatureConvention class because:

  • DefaultPageController is in a completely different place;
  • Optimizely for pages and blocks adds in suffix: Page, Block parts in view path;

To make this work, we need to update the DefaultPageController:

[TemplateDescriptor(Inherited = true)]
public class DefaultPageController : PageControllerBase<PageDataBase>
{
    public ViewResult Index()
    {
        var pageType = CurrentPage.PageTypeName;
        return View($"/Features/{pageType.Replace("Page", "")}/Views/{pageType}.cshtml",
                    CurrentPage);
    }
}

Now the view is passed directly and the Page suffix is removed because the feature name shouldn't contain this, it looks weird when we use paths like this in namespace: Features/StartPage/Models/StartPage.

Next, we need to implement the IViewLocationExpander interface:

public class FeatureViewLocationExpander : IViewLocationExpander
{
    public void PopulateValues(ViewLocationExpanderContext context)
    {
        // see: https://stackoverflow.com/questions/36802661/what-is-iviewlocationexpander-populatevalues-for-in-asp-net-core-mvc
        context.Values["action_displayname"] 
            = context.ActionContext.ActionDescriptor.DisplayName;
    }

    public IEnumerable<string> ExpandViewLocations(
        ViewLocationExpanderContext context, 
        IEnumerable<string> viewLocations)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (viewLocations == null)
            throw new ArgumentNullException(nameof(viewLocations));

        var controllerDescriptor = context.ActionContext.ActionDescriptor;
        var featureName = controllerDescriptor.Properties.ContainsKey("feature")
            ? controllerDescriptor.Properties["feature"] as string
            : "";

        foreach (var location in viewLocations)
        {
            yield return location
                .Replace("{feature}", featureName);
        }
    }
}

And add the AddFeatureFolders service:

public static class ServiceCollectionExtensions
{
    public static IMvcBuilder AddFeatureFolders(this IMvcBuilder services)
    {
        if (services == null)
            throw new ArgumentNullException(nameof(services));

        services.AddMvcOptions(o => o.Conventions.Add(new ControllerFeatureConvention()))
            .AddRazorOptions(o =>
            {
                o.ViewLocationFormats.Add(@"{feature}\Views\{0}.cshtml");
                o.ViewLocationFormats.Add(@"{feature}\{0}.cshtml");
                o.ViewLocationFormats.Add(@"\Features\{0}.cshtml");

                o.ViewLocationExpanders.Add(new FeatureViewLocationExpander());
            });

        return services;
    }
}

This service adds our ControllerFeatureConvention, ViewLocationFormats and our FeatureViewLocationExpander.

@"{feature}\Views\{0}.cshtml" - used for features containing a controller
@"{feature}\{0}.cshtml" - the same, but when viewed at the root of the feature
@"\Features\{0}.cshtml" - used for block/view components (please read the following section to get more information about component structure)

 

Blocks are now View components

This section will show by example how to create and place a block component.

First of all, you need to create the block data:

public class SectionBlock : BlockData
{
    [Display(Order = 100)]
    [CultureSpecific]
    public virtual string Heading { get; set; }

    [Display(Name = "Main content area", Order = 150)]
    [AllowedTypes(typeof(TextBlock))]
    [CultureSpecific]
    public virtual ContentArea MainContentArea { get; set; }
}

A block component can be created in such manner:

public class SectionViewComponent : BlockComponent<SectionBlock>
{
    protected override IViewComponentResult InvokeComponent(SectionBlock currentBlock)
    {
        return View(currentBlock);
    }
}

We decided to use the ViewComponent suffix for all components (Optimizely, .net)

After that, you need to create a view named Default.cshtml:

@using EPiServer.Web.Mvc.Html

@model SectionBlock

<section class="column-texts">
    <h2 class="column-texts__heading heading-1">
        @Html.PropertyFor(m => m.Heading)
    </h2>

    <div class="column-texts__content scoped">
        @Html.PropertyFor(m => m.MainContentArea)
    </div>
</section>

And all these three files need to be placed in the Components/Section folder.

In practice, block components can be used for any page and we have moved all block components to Features/Components as follows:

Screenshot 2022-01-19 144306.png

The previous section mentioned that Optimizely automatically puts the Page and Block parts in the view path name, and to use the folder structure of such components, we must remove the extra block suffix from the view name. This can be done with a few changes in the FeatureViewLocationExpander class:

public class FeatureViewLocationExpander : IViewLocationExpander
{
    public void PopulateValues(ViewLocationExpanderContext context)
    {
        // see: https://stackoverflow.com/questions/36802661/what-is-iviewlocationexpander-populatevalues-for-in-asp-net-core-mvc
        context.Values["action_displayname"] 
            = context.ActionContext.ActionDescriptor.DisplayName;
    }

    public IEnumerable<string> ExpandViewLocations(
        ViewLocationExpanderContext context, 
        IEnumerable<string> viewLocations)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (viewLocations == null)
            throw new ArgumentNullException(nameof(viewLocations));

        var controllerDescriptor = context.ActionContext.ActionDescriptor;
        var viewName = GetViewName(context.ViewName);
        var featureName = controllerDescriptor.Properties.ContainsKey("feature")
            ? controllerDescriptor.Properties["feature"] as string
            : "";

        foreach (var location in viewLocations)
        {
            yield return location
                .Replace("{feature}", featureName)
                .Replace("{0}", viewName);
        }
    }

    private string GetViewName(string viewName)
    {
        // Workaround: remove Block part from view name because folder structure for blocks used without Block suffix
        if (viewName.Contains("Components") && viewName.Contains("Block"))
        {
            var splitedParts = viewName.Split("/");
            splitedParts[1] = splitedParts[1].Replace("Block", string.Empty);
            return string.Join('/', splitedParts);
        }

        return viewName;
    }
}

The ViewName for components is in the following format: Components/SectionBlock/Default.cshtml and our goal is to remove the extra Block suffix from the SectionBlock.


Summary

With help of these code snippets and tips you can use features folder structure as in previous Optimizely versions, but more efficiently.