Episerver custom error page

When we wanted to use a 404 page for our Episerver projects we used to implement the Getta 404 Handler plugin, but this plugin only allows you to use a static page when a response code 404 appears, and is also limited to that response code. This blog is about how to create an error page which an editor can modify and still will work as expected when any server error occurs. I recommend that you start your code using as base the Episerver Foundation Cms site, but we will explain it as we started the site from scratch.

The first thing we are going to do is create an error layout for a generic error page which will not be editable, but will help as fallback when an unknown error is found. This layout is the same as the LoginLayout.cshtml from the Episerver Foundation Cms repository. We are going to call it _ErrorLayout.cshtml and locate it in this directory /Features/Shared/Views/.

@{
    Layout = null;
}
@model Foundation.Features.ViewModels.UserViewModel
<!DOCTYPE html>

<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <meta name="author" content="">
    <title>@Model.Title</title>
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,100i,300,300i,400,400i,500,500i,700,700i,900,900i" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css?family=Montserrat&display=swap" rel="stylesheet">
    <link rel="icon" href="~/Assets/icons/episerver.png" type="image/x-icon" />
    <link rel="shortcut icon" href="~/Assets/icons/episerver.png" type="image/x-icon" />
    <link href="~/Assets/scss/vendors/font-awesome-5.9.0/css/all.css" rel="stylesheet" type="text/css" />
    <link href="~/Assets/scss/main.min.css" rel="stylesheet" type="text/css" />
</head>
<body class="login__background">
    <div class="loading-box" style="display: none">
        <div class="loader"></div>
    </div>
    @RenderBody()
    <script src="~/Assets/js/vendors/jquery/jquery-3.4.0.min.js" type="text/javascript"></script>
    <script src="~/Assets/js/vendors/jquery/jquery.validate.min.js" type="text/javascript"></script>
    <script src="~/Assets/js/vendors/jquery/jquery.validate.unobtrusive.min.js" type="text/javascript"></script>
    <script src="~/Assets/js/vendors/bootstrap-4.3.1/bootstrap.min.js" type="text/javascript"></script>
    <script src="~/Assets/js/vendors/feather-icons/feather.min.js" type="text/javascript"></script>
    <script src="~/Assets/js/vendors/axios/axios.min.js" type="text/javascript"></script>
    <script src="~/Assets/js/main.min.js"></script>
</body>
</html>

Now, we will create the UserViewModel type which is a little bit modified from the version in Episerver foundation cms.

namespace Foundation.Features.ViewModels
{
    public class UserViewModel
    {
        public string Logo { get; set; } // Logo is not used in this example.
        public string Title { get; set; }
        public string ErrorMessage { get; set; }
        public string StackTrace { get; set; }

        public UserViewModel()
        {
            // Do nothing
        }
    }
}

Then, we will add the ErrorPage.cshtml file which will be the view to show when an unknown error occurs and uses the layout _ErrorLayout.cshtml. This file will be located under the folder /Features/Shared/Error/ and is similar to the ErrorPage.cshtml in the Episerver Foundation Cms project.

@{
    Layout = "~/Features/Shared/Views/_ErrorLayout.cshtml";
}
@model Foundation.Features.ViewModels.UserViewModel
@{
    var logo = Model.Logo;
}

<div class="container">
    <div class="row">
        <div class="col-12">
            <div class="row">
                <div class="col-12 login__row">
                    <a href="/">
                        <img src="@logo" alt="Home" class="img-fluid" style="max-height: 80px" />
                    </a>
                </div>
            </div>
            <h1 class="errorpage">500</h1>
            <h2 class="errorpage">Unexpected Error <b>:(</b></h2>
            @if (System.Web.HttpContext.Current.IsDebuggingEnabled)
            {
                <p>@Model.ErrorMessage</p>
                <p>@Model.StackTrace</p>
            }

        </div>
    </div>
</div>

After that, we will add the controller for the ErrorPage.cshtml view which will be called ErrorController.cs and will be located under the same directory as the view /Features/Shared/Error/. The error controller is similar to the one found in the Episerver Foundation Cms project with some modifications that are properly commented.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using EPiServer;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.Filters;
using EPiServer.ServiceLocation;
using Foundation.Features.Error.Pages.ErrorDetail;
using Foundation.Features.ViewModels;
using Foundation.Features.ViewModels.Pages.Content;

namespace Foundation.Features.Shared.Error
{
    public class ErrorController : Controller
    {
        private static readonly Lazy<IContentTypeRepository> _contentTypeRepository = new Lazy<IContentTypeRepository>(() => ServiceLocator.Current.GetInstance<IContentTypeRepository>());
        private static readonly Lazy<IPublishedStateAssessor> _publishedStateAssessor = new Lazy<IPublishedStateAssessor>(() => ServiceLocator.Current.GetInstance<IPublishedStateAssessor>());

        [HttpGet]
        public ActionResult Index(Exception exception)
        {
            var errorDetailPage = GetPages<ErrorDetailPage>()
                .SingleOrDefault(x => x.ErrorCode == HttpContext.Response.StatusCode); // Find an error detail page with the corresponding error response code

            if (errorDetailPage != null) // If it finds it, show that page. If it does not, show the generic one.
            {
                return View("~/Features/Error/Pages/ErrorDetail/Index.cshtml", ContentViewModel.Create(errorDetailPage));
            }

            var model =  new UserViewModel
            {
                Title = "Error",
                ErrorMessage = exception?.Message ?? "Not found",
                StackTrace = exception?.StackTrace ?? "Not stacktrace",
                Logo = ""
            };
            return View("~/Features/Shared/Error/ErrorPage.cshtml", model);
        }

        public static IEnumerable<T> GetPages<T>() where T : PageData
        {
            var startPage = ContentReference.StartPage;
            if (startPage == null || startPage.ID == 0)
            {
                return new List<T>();
            }

            // Define a criteria collection to do the search
            var criterias = new PropertyCriteriaCollection();

            // Create criteria for searching page types
            var criteria = new PropertyCriteria
            {
                Condition = CompareCondition.Equal,
                Type = PropertyDataType.PageType,
                Name = "PageTypeID",
                Value = _contentTypeRepository.Value.Load<T>().ID.ToString()
            };

            // Add criteria to collection
            criterias.Add(criteria);

            // Searching from Start Page
            var repository = ServiceLocator.Current.GetInstance<IPageCriteriaQueryService>();
            var pages = repository.FindPagesWithCriteria(startPage, criterias);

            var result = pages != null && pages.Any() ? pages
                .Where(IsContentPublished)
                .Select(z => (T)z) : new List<T>();

            return result;
        }

        public static bool IsContentPublished(IContent content)
        {
            return _publishedStateAssessor.Value.IsPublished(content, PagePublishedStatus.Published);
        }
    }
}

The purpose of this controller, is to try to find an Error Detail Page (defined later) with the corresponding response code and if does, it will show that page, but if it does not, it will show the generic error page.

The methods GetPages and IsContentPublished and all its dependencies should be outside of the controller in another class, but for practicality we will put them as part of it.

There is also a helper method ContentViewModel.Create(…), this helper can be found in the Episerver foundation CMS project here. However, a stripped implementation of the helper with its interface will be shown below to reduce the complexity of the code. The main purpose of this code is to return a view model with several more properties than only the page model, but the stripped version only sends the page model in order to simplify the code.

using EPiServer.Core;

namespace Foundation.Feature.ViewModels.Pages.Content.Interfaces
{
    public interface IContentViewModel<out TContent> where TContent : IContent
    {
        TContent CurrentContent { get; }
    }
}
using EPiServer.Core;
using Foundation.Feature.ViewModels.Pages.Content.Interfaces;

namespace Foundation.Features.ViewModels.Pages.Content
{
    public class ContentViewModel<TContent> : IContentViewModel<TContent> where TContent : IContent
    {
        public ContentViewModel() : this(default)
        {
            // Do nothing
        }

        public ContentViewModel(TContent currentContent)
        {
            CurrentContent = currentContent;
        }

        public TContent CurrentContent { get; set; }
    }

    public static class ContentViewModel
    {
        public static ContentViewModel<T> Create<T>(T content) where T : IContent => new ContentViewModel<T>(content);
    }
}

Now, we will define the page type ErrorDetailPage, its view and controller and all of them will be located under this folder /Features/Error/Pages/ErrorDetail/. This page type will be the one which the editor can modify in the CMS.

First, we will create the page type ErrorDetailPage.cs

using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using EPiServer.Core;

namespace Foundation.Features.Error.Pages.ErrorDetail
{
    [ContentType(DisplayName = "Error Detail Page",
        GUID = "1a1a5455-e99a-440e-8944-0c303cf4269a",
        Description = "Allow content authors to create an error detail page")]
    public class ErrorDetailPage : PageData
    {
        #region Content

        [Required]
        [Display(Name = "Error code", GroupName = SystemTabNames.Content, Order = 100)]
        public virtual int ErrorCode { get; set; }

        [CultureSpecific]
        [Display(Name = "Heading", GroupName = SystemTabNames.Content, Order = 200)]
        public virtual string Heading { get; set; }

        [CultureSpecific]
        [Display(Name = "Main Content", GroupName = SystemTabNames.Content, Order = 300)]
        public virtual XhtmlString MainContent { get; set; }

        [CultureSpecific]
        [Display(Name = "Main content area", GroupName = SystemTabNames.Content, Order = 400)]
        public virtual ContentArea MainContentArea { get; set; }

        #endregion
    }
}

The most relevant part of the ErrorDetailPage is the ErrorCode property, which expects an integer that will try to match the http response code.

Second, we will create the controller for the ErrorDetailPage which will be called ErrorDetailPageController.cs. It again uses the ContentViewModel.Create(…) method

using EPiServer.Web.Mvc;
using System.Web.Mvc;
using Foundation.Features.ViewModels.Pages.Content;

namespace Foundation.Features.Error.Pages.ErrorDetail
{
    public class ErrorDetailPageController : PageController<ErrorDetailPage>
    {
        public ActionResult Index(ErrorDetailPage currentPage)
        {
            var model = ContentViewModel.Create(currentPage);
            return View("~/Features/Error/Pages/ErrorDetail/Index.cshtml", model);
        }
    }
}

Finally, we will add the view for the ErrorDetailPage called Index.cshtml and its layout which is different from the layout used for the generic error page.

@model Foundation.Features.ViewModels.Pages.Content.ContentViewModel<Foundation.Features.Error.Pages.ErrorDetail.ErrorDetailPage>

@{
    Layout = "~/Features/Shared/Views/_Layout.cshtml";
}

<div class="wrapper">
    <div class="page-section">
        <h1>
            @Html.PropertyFor(x => x.CurrentContent.Heading)
        </h1>
        <div>
            @Html.PropertyFor(x => x.CurrentContent.MainContent)
        </div>
        
        @Html.PropertyFor(x => x.CurrentContent.MainContentArea)
    </div>
</div>

The layout for the ErrorDetailPage called _Layout.cshtml will be located under the directory /Features/Shared/Views/

@using EPiServer.Framework.Web
@using EPiServer.Framework.Web.Mvc.Html

@model Foundation.Feature.ViewModels.Pages.Content.Interfaces.IContentViewModel<IContent>

@{
    Layout = "~/Features/Shared/Views/_MasterLayout.cshtml";
}

@section AdditionalStyles {
    @RenderSection("AdditionalStyles", required: false)
}

@Html.RenderEPiServerQuickNavigator()

<main>
    @RenderSection("Top", required: false)
    @RenderBody()
    @RenderSection("Bottom", required: false)
</main>

@RenderSection("AdditionalScripts", required: false)
@Html.RequiredClientResources(RenderingTags.Footer)

The latest bit of code is to detect the error in the Global.asax.cs file and handle it using the Error Controller.

using System;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using System.Web.UI;
using Foundation.Features.Shared.Error;

namespace Foundation
{
    public class EPiServerApplication : EPiServer.Global
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            ScriptManager.ScriptResourceMapping.AddDefinition("jquery", new ScriptResourceDefinition
            {
                Path = "~/Assets/js/vendors/jquery/jquery-3.4.0.min.js",
            });
        }

        protected override void RegisterRoutes(RouteCollection routes)
        {
            base.RegisterRoutes(routes);

            routes.MapRoute(
              name: "Default",
              url: "{controller}/{action}/{id}",
              defaults: new { action = "Index", id = UrlParameter.Optional });
        }

        protected void Application_Error(object sender, EventArgs e)
        {
            // Grab information about the last error occurred 
            var exception = Server.GetLastError();

            // Uncomment these lines if you are using 404 Handler for Episerver plugin
            /*var httpContext = ((HttpApplication)sender).Context;
            /httpContext.Response.Clear();
            httpContext.ClearError();
            httpContext.Response.TrySkipIisCustomErrors = true;*/

            var routeData = new RouteData();
            routeData.Values["controller"] = "error";
            routeData.Values["action"] = "index";
            routeData.Values["exception"] = exception;
            using var controller = new ErrorController();
            ((IController)controller).Execute(
                new RequestContext(new HttpContextWrapper(httpContext), routeData));
        }
    }
}

With all that code, now you can create a new error detail page as an editor and write down which error you want to capture. In the example below we created a page for the 404 error

And then, after publishing the page, you can try to access a url that you know does not exists in your site, and it will display the 404 page that you just created with the corresponding response code.

And that is all. Probably a bit more extensive than it should be, but still a good option that gives the flexibility to editors to add custom error pages without any limitation. I hope it will help someone and as always keep learning !!!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑

%d bloggers like this: