When we are working with the Episerver website there are few page types that we want Content editors to create only one instance of the page. Such as the “Search result page”. We only want one search result page on the whole website. Few similar examples are;
- Home Page (Start Page)
- Checkout Page
- Basket Page
- Blog Listing page
- Site Setting Page
In order to fulfill this requirement, first of all, I have created a custom attribute called “SingleInstancesAttribute”
using System;
namespace Foundation.Cms.Attributes
{
[AttributeUsage(AttributeTargets.Class)]
public class SingleInstancesAttribute : Attribute
{
public enum InstanceScope
{
Site,
SameContentTree,
}
public InstanceScope Scope { get; set; }
}
}
As you can see I’m using AttributeUsage attribute from System class. This is because I want to control the manner in which it is been used. For example, the indicated attribute class must derive from Attribute, either directly or indirectly. You can check detailed documentation and other options on the Microsoft website.
I have also defined the scope element of this attribute.
Site Scope: The instance of page type can not be created more than once on whole website
SameContentTree Scope: The instance of page type can not be created more than once on the same content tree. Such as their ParentLink can not be the same.
Now I can add this attribute on those page types that I want only one instance. In the below example, I have applied this attribute on “SearchResultPage” page type of the Episerver Foundation example site.
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Foundation.Cms.Attributes;
namespace Foundation.Cms.Pages
{
[ContentType(DisplayName = "Search Results Page",
GUID = "6e0c84de-bd17-43ee-9019-04f08c7fcf8d",
Description = "Page to allow customer to search the site",
GroupName = CmsGroupNames.Content)]
[ImageUrl("~/assets/icons/cms/pages/CMS-icon-page-03.png")]
[SingleInstances(Scope = SingleInstancesAttribute.InstanceScope.Site)]
public class SearchResultPage : FoundationPageData
{
[CultureSpecific]
[Display(Name = "Top content area", Order = 210)]
public virtual ContentArea TopContentArea { get; set; }
[CultureSpecific]
[Display(
Name = "Show recommendations",
Description = "This will determine whether or not to show recommendations", Order = 220)]
public virtual bool ShowRecommendations { get; set; }
public override void SetDefaultValues(ContentType contentType) => ShowRecommendations = true;
}
}
The next step is to create a Validator. The validator will tell Episerver that something needs validation before publishing a page. You can consider it a Pre-Publish event.
In the below validator example, I’m using Episerver find to get all instances of The page type and checking it against Scope of Attribute. You can use Content Loader to do the same (I find Episerver Find is more efficient in such queries)
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using EPiServer.Core;
using EPiServer.Validation;
using Foundation.Cms.Attributes;
using Foundation.Cms.Pages;
using Foundation.Find.Cms;
namespace Foundation.Demo.Validation
{
public class SingleInstancesValidator : IValidate<PageData>
{
private readonly ICmsSearchService _searchService;
public SingleInstancesValidator(ICmsSearchService seaechService)
{
_searchService = seaechService;
}
public IEnumerable<ValidationError> Validate(PageData instance)
{
var singleInstanceAttribute = instance.GetType().GetCustomAttribute<SingleInstancesAttribute>(true);
if (singleInstanceAttribute == null)
{
return Enumerable.Empty<ValidationError>();
}
// call search service to get all existing instances of page type
var existingInstances = _searchService.SearchByPageType<SearchResultPage>().ToList();
if (existingInstances.Any())
{
if (existingInstances.Count > 0)
{
// if we already have a instance of this page in find then check scope of instance
if (singleInstanceAttribute.Scope == SingleInstancesAttribute.InstanceScope.Site)
{
// Error
return new[]
{
new ValidationError
{
ErrorMessage =
$"Only one instances of this page type can exist.",
PropertyName = "PageType",
Severity = ValidationErrorSeverity.Error,
ValidationType = ValidationErrorType.StorageValidation
}
};
}
else if (singleInstanceAttribute.Scope == SingleInstancesAttribute.InstanceScope.SameContentTree)
{
if (existingInstances.Any(x => x.ParentLink == instance.ParentLink))
{
//Error
return new[]
{
new ValidationError
{
ErrorMessage =
$"Only one instances of this page type can exist at this level",
PropertyName = "PageName",
Severity = ValidationErrorSeverity.Error,
ValidationType = ValidationErrorType.StorageValidation
}
};
}
}
}
}
return Enumerable.Empty<ValidationError>();
}
}
}
Now if your find index has already crawled all pages of the website and if you start to create another instance of SearchResult page it gives you an error “Only one instance of this page type can exist.” and don’t let you publish the page.
Below is the code of new method I have created in ICmsSearchService to give me all instances of a page type.
public IEnumerable<T> SearchByPageType<T>() where T : PageData
{
var productSearch = _findClient.Search<T>();
productSearch = productSearch.FilterForVisitor();
return productSearch.GetContentResult();
}
I have implemented this example on the Episerver Foundation example site. You can find code by visiting following Git repo
https://github.com/nulhaq/EpiserverSingleInstanceValidator
I did something similiar a couple of years ago.
https://world.episerver.com/blogs/Per-Nergard/Dates/2016/4/limit-block-and-page-types-to-be-created-only-once-updated/
This is great, thanks for posting.
Unfortunately for me though this code will not work with the DXC service since it uses EPiServer “Search” which is not supported on DXC and Azure 🙁
Hi Dave
I have used Episerver Find not Episerver search. This code is compatible with DXC