Getting more out of the Block List Editor again

tagged with .NET 6 Razor v10 v9

This is a follow up to last year's great post by Dave Woestenborghs, where he goes through how you can control, hide or schedule a block's "published" state to empower your editors. He also went through how to render blocks in the backoffice using the partial views the site would use for the front end, reducing a developer's maintence and start up cost. You can find it in last year's posts - https://24days.in/umbraco-cms/2021/advanced-blocklist-editor/

Using this code as a base I have also added the ability to use ViewComponents directly for blocks, as well as previewing with them! This is great for those complex blocks where you want to manipulate or further hydrate the blocks data.

In case you havent used V9 or .NET Core lets talk ViewComponents. Coming from previous versions of Umbraco and dotnet I feel the best way of looking at them is as child actions (Html.Action()), however they don't use model binding so you can easily pass anything to them. Although, with the ability to inject services into Razor views, the need for separation of simple logic or service calls might not be something you need to do as often, depending on your preferences.

Lets take a look at a ViewComponent for a frequently asked questions block.


public class FrequentlyAskedQuestionsBlockViewComponent : ViewComponent
    {
        private readonly IUmbracoContextAccessor _umbracoContextAccessor;
        private readonly IPublishedValueFallback _publishedValueFallback;

        private const string ViewPath = "~/Views/Partials/blocklist/Components/FrequentlyAskedQuestionsBlock.cshtml";

        public FrequentlyAskedQuestionsBlockViewComponent(IUmbracoContextAccessor umbracoContextAccessor, IPublishedValueFallback publishedValueFallback)
        {
            _umbracoContextAccessor = umbracoContextAccessor;
            _publishedValueFallback = publishedValueFallback;
        }

        public IViewComponentResult Invoke(BlockListItem<FrequentlyAskedQuestionsBlock> block)
        {
            var model = new FaqBlockViewModel(block.Content, _publishedValueFallback);

            using var umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext();
            var dataFolder = umbracoContext.Content.GetAtRoot().FirstOrDefault(x => x.IsDocumentType(DataFolder.ModelTypeAlias));

            if (dataFolder != null)
            {
                foreach (var faqFolder in dataFolder.Children<FrequentlyAskedQuestionFolder>())
                {
                    model.FaqItems.AddRange(faqFolder.Children<FrequentlyAskedQuestion>());
                }
            }
            return model.FaqItems.Any() ? View(ViewPath, model) : Content(string.Empty);
        }
    }

You might notice in the example code above I am specifying the full view path, this is to keep it in the Umbraco style instead of the default just View(model) you would use which would be /Components/{View Component Name}/{View Name} which in this case would be /Components/FrequentlyAskedQuestionsBlock/Default.cshtml. Unfortunately this is the only way to have this exact path, as the above format is required, the best we can do is add a view location for /Views/Partials/blocklist/ and moving our view into a FrequentlyAskedQuestionsBlock folder and renaming it Default. In Startup.cs we need to change AddBackOffice() in ConfigureServices to add some RazorOptions as below.


public void ConfigureServices(IServiceCollection services)
        {
            services.AddUmbraco(_env, _config)
                .AddBackOffice(c =>
                c.AddRazorOptions(r =>
                {
                    r.ViewLocationFormats.Add("/Views/Partials/blocklist/{0}.cshtml");
                }
                ))
                .AddWebsite()
                .AddComposers()
                .Build();
        }

So now our View path is /Views/Partials/blocklist/Components/FrequentlyAskedQuestionsBlock/Default.cshtml at least it is almost the same as a partial view from before and we don't need to specifiy anything when calling View, other than our model of course.

Lets update the Block list rendering cshtml to allow for ViewComponents. We need to work out if this block has a view component or not, in order to do this we are going to inject Microsoft.AspNetCore.Mvc.ViewComponents.IViewComponentSelector into our view and use that to select a component by name using the block's alias, if this returns a descriptor we can then use it to call the component and if not we will fallback to a Partial.


@using _24Days.Core.Services
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockListModel>
@inject IBlockPublicationCheckService publicationCheckService;
@inject Microsoft.AspNetCore.Mvc.ViewComponents.IViewComponentSelector Selector
@{
    if (!Model.Any()) { return; }
}
<div class="umb-block-list">
    @foreach (var block in Model)
    {
        if (block?.ContentUdi == null) { continue; }
        var data = block.Content;

        var isBlockPublished = publicationCheckService.IsBlockPublished(block);
        if (!isBlockPublished)
        {
            continue;
        }

        var viewAlias = data.ContentType.Alias.ToFirstUpper();
        var viewComponent = Selector.SelectComponent(viewAlias);
        if (viewComponent != null)
        {

            @await Component.InvokeAsync(viewComponent.TypeInfo.AsType(), block)
        }
        else
        {
            @await Html.PartialAsync("blocklist/Components/" + viewAlias, block)
        }
    }
</div>

Everything is working in the front end, but what about the backoffice, it looks like we broke the previews. In order to do this we first need to create a new service for generating the Markup for a block, otherwise the preview controller is going to get a little hard to follow! I am not going to go through the code fully, as it's mainly the same as before but with a few extra bits for ViewComponents. I will be shortening the code samples from this point but check out the github link at the end for the full versions.


public sealed class BackOfficePreviewService : IBackOfficePreviewService
{
//Our entry point from the controller
	public async Task<string> GetMarkupForBlock(
		BlockItemData blockData,
		ControllerContext controllerContext)
	{
		//element/block setup

        var contentAlias = element.ContentType.Alias.ToFirstUpper();

		var viewComponent = _viewComponentSelector.SelectComponent(contentAlias);
		if (viewComponent != null)
		{
			return await GetMarkupFromViewComponent(controllerContext, viewData, viewComponent, blockListItem);
		}

        //this is much the same as last year
		return await GetMarkFromPartial(controllerContext, viewData, contentAlias);
	}

As you can see it's the same method of detection, so lets look at how we handle the rendering.


private async Task<string> GetMarkupFromViewComponent(ControllerContext controllerContext,
		ViewDataDictionary viewData,
		ViewComponentDescriptor viewComponent,
		IBlockReference? blockListItem)
	{
		await using var sw = new StringWriter();
		var viewContext = new ViewContext(
			controllerContext,
			new FakeView(),
			viewData,
			new TempDataDictionary(controllerContext.HttpContext, _tempDataProvider),
			sw,
			new HtmlHelperOptions());
		_viewComponentHelperWrapper.Contextualize(viewContext);

		var result = await _viewComponentHelperWrapper.InvokeAsync(viewComponent.TypeInfo.AsType(), blockListItem);
		result.WriteTo(sw, HtmlEncoder.Default);
		return sw.ToString();
	}

There are a couple of things of note here, FakeView is a blank implementation of an IView, it's not used but the Context requires one. The other is _viewComponentHelperWrapper. This is a wrapper class to make unit testing easier as Contextualize is a method of DefaultViewComponentHelper but MVC only registers IViewComponentHelper which does not have it. The casting of which we will handle when composing.

That's it! You can now use both Partials or ViewComponents. See the full project at https://github.com/Matthew-Wise/24-days-block-list-article/tree/block-list-vc-preview

But what about the Block Grid? Well at the time of writing it's in RC so here is a working prototype! - https://github.com/Matthew-Wise/24-days-block-grid-previews


Tech Tip: JetBrains Rider

Check it out here