ExcludeDeleted(): Prevent Trashed Content from Appearing in Search Results
Introduction
In Optimizely CMS, content that is moved to the trash can still appear in search results if it’s not explicitly excluded using the IsDeleted
property on IContent
. This behavior can clutter search results and lead to a confusing user experience, especially if the "deleted" content remains accessible when it shouldn't be. Unfortunately, Optimizely’s documentation lacks a straightforward way to filter out trashed content, so we developed a custom solution that excludes trashed items from search results.
This solution leverages Optimizely Content Events to detect when content is moved to or from the trash and applies an IsDeleted
flag to custom page templates that inherit from SitePageData
, our project’s base page class. By using these events, we ensure real-time updates to the search index, providing a consistent and clear search experience for users.
Overview of Optimizely Content Events
Optimizely Content Events are essential for managing content lifecycle actions such as creation, updates, movement, and deletion. With these events, developers can trigger specific actions in response to content changes, enabling them to integrate with other systems or handle special tasks for particular content types.
Here’s a quick overview of common content events in Optimizely:
- CreatedContent: Fired when new content is created.
- PublishedContent: Triggered when content is published.
- DeletedContent: Fires when content is permanently removed from the CMS.
- MovedContent: Activated when content is moved to a new location or to the trash.
- SavingContent: Occurs before content is saved, often used for validation or setting default values.
For a more in-depth look at Content Events, check out Daniel Ovaska’s blog on Content Events.
Solution Overview
Our custom solution involves setting up an OnMovedContentToTrash
event handler that:
- Detects when content is moved to or from the trash.
- Sets an
IsDeleted
flag on custom page types that inherit fromSitePageData
. - Updates the content index in Optimizely Find to keep search results accurate.
We’ll break down each part of this solution below, including a reusable method that dynamically identifies all custom page types derived from SitePageData
and efficiently applies the IsDeleted
flag.
Implementation
Step 1: Setting up the OnMovedContentToTrash
Event Handler
In our OnMovedContentToTrash
method, we monitor if content has been moved to or from the trash. Based on its location, we set the IsDeleted
flag to true (if it’s in the trash) or false (if it’s restored). This updated information is saved in the CMS and re-indexed in Optimizely Find to ensure search results reflect the current content state.
Step 2: Creating the Processor Action for Each Page Type
To apply IsDeleted
on each page type without reflection at runtime, we use an expression tree to create an action for each type. This approach avoids runtime performance overhead and prevents potential issues with reflection.
Step 3: Defining the ProcessContent
Method
This method applies the IsDeleted
flag to the specific content type and re-indexes it in Optimizely Find to ensure that it appears or disappears from search results based on its status.
Here's the code for the above steps:
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule), typeof(EPiServer.Commerce.Initialization.InitializationModule))]
public class ContentEventInitialization : IInitializableModule
{
private IClient _findClient { get; set; }
private Injected<ICmsContentService> _cmsContentService;
private IContentTypeRepository _contentTypeRepository { get; set; }
private static readonly Injected<IHttpContextAccessor> _httpContextAccessor;
private static readonly Injected<IConfiguration> _configuration;
private static readonly Injected<IWebHostEnvironment> _environment;
private static readonly ILogger _logger = LogManager.GetLogger(typeof(ContentEventInitialization));
public void Initialize(InitializationEngine context)
{
_findClient = SearchClient.Instance;
_contentTypeRepository = context.Locate.Advanced.GetInstance<IContentTypeRepository>();
var contentEventsService = _contentEvents.Service;
contentEventsService.MovedContent += OnMovedContentToTrash;
contentEventsService.PublishedContent += OnContentPublished;
contentEventsService.CheckedInContent += OnCheckedInContent;
}
public void Uninitialize(InitializationEngine context)
{
var contentEventsService = _contentEvents.Service;
contentEventsService.MovedContent -= OnMovedContentToTrash;
contentEventsService.PublishedContent -= OnContentPublished;
contentEventsService.CheckedInContent -= OnCheckedInContent;
}
/// <summary>
/// Handles the event when content is moved to or from the trash. Updates the IsDeleted property
/// of specific custom page types and re-indexes the content in Optimizely Find.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The content event arguments containing details about the moved content.</param>
private void OnMovedContentToTrash(object sender, ContentEventArgs e)
{
_logger.Information($"Moved content to trash fired for content {e.ContentLink.ID}");
if (e is MoveContentEventArgs eventArgs)
{
var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
// Dictionary to store processors for each custom page type
var contentProcessors = new Dictionary<Type, Action<IContent, bool>>();
var sitePageDataType = typeof(SitePageData);
// Get all types that inherit from SitePageData in the current assembly
var pageTypes = _contentTypeRepository
.List()
.Where(x => x.ModelType != null)
.Select(x => x.ModelType)
.Where(x => sitePageDataType.IsAssignableFrom(x) && x.IsClass && !x.IsAbstract);
// Populate the dictionary with actions for each page type
foreach (var pageType in pageTypes)
{
var processor = CreateProcessorAction(pageType);
contentProcessors[pageType] = processor;
}
// Determine if content is moved to or from the trash, setting IsDeleted accordingly
bool isDeleted = eventArgs.TargetLink.ID == ContentReference.WasteBasket.ID;
bool isRestored = eventArgs.OriginalParent.ID == ContentReference.WasteBasket.ID;
if (isDeleted || isRestored)
{
var contentType = e.Content.GetType().BaseType;
if (contentProcessors.TryGetValue(contentType, out var processor))
{
processor.Invoke(e.Content, isDeleted);
}
}
}
}
/// <summary>
/// Creates a strongly-typed action for processing a specific page type when content is moved
/// to or from the trash. This avoids the need for reflection at runtime.
/// </summary>
/// <param name="pageType">The type of the page for which to create the processor action.</param>
/// <returns>An action that processes the page type when content is moved to or from the trash.</returns>
private Action<IContent, bool> CreateProcessorAction(Type pageType)
{
// Define parameters for the lambda: (IContent content, bool isDeleted)
var contentParam = Expression.Parameter(typeof(IContent), "content");
var isDeletedParam = Expression.Parameter(typeof(bool), "isDeleted");
// Cast content to the specific page type
var castContent = Expression.Convert(contentParam, pageType);
// Create method call for ProcessContent<T> using the specific page type
var method = typeof(ContentEventInitialization)
.GetMethod(nameof(ProcessContent), BindingFlags.NonPublic | BindingFlags.Instance)
.MakeGenericMethod(pageType);
// Call ProcessContent<T>((T)content, isDeleted)
var body = Expression.Call(Expression.Constant(this), method, castContent, isDeletedParam);
// Compile into a lambda expression: (IContent content, bool isDeleted) => ProcessContent<T>((T)content, isDeleted)
var lambda = Expression.Lambda<Action<IContent, bool>>(body, contentParam, isDeletedParam);
return lambda.Compile();
}
/// <summary>
/// Updates the IsDeleted property for a specific content type and re-indexes it in Optimizely Find.
/// This method is intended to be called by actions generated for each page type.
/// </summary>
/// <typeparam name="T">The type of the content to process, which must inherit from PageData.</typeparam>
/// <param name="content">The content to be processed.</param>
/// <param name="isDeleted">A boolean indicating whether the content is marked as deleted (true) or restored (false).</param>
private void ProcessContent<T>(IContent content, bool isDeleted) where T : PageData
{
var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
if (contentRepository.Get<T>(content.ContentLink).CreateWritableClone() is T writableContent)
{
writableContent.IsDeleted = isDeleted;
if (isDeleted)
{
writableContent.Deleted = DateTime.Now;
writableContent.DeletedBy = PrincipalInfo.CurrentPrincipal.Identity.Name;
}
else
{
writableContent.Deleted = null;
writableContent.DeletedBy = null;
}
contentRepository.Save(writableContent, SaveAction.SkipValidation, AccessLevel.NoAccess);
_findClient.Index(writableContent);
}
}
}
Using ExcludeDeleted():
var detailPageQuery = _searchClient.Search<StorytellingPage>()
.ExcludeDeleted()
.FilterForVisitor()
.FilterOnCurrentSite()
.CustomFilterForVisitor()
.Take(PAGINATION);
Conclusion
By creating a generic solution using Optimizely Content Events, we can dynamically detect content type changes, set an IsDeleted
flag, and update the search index in real time. This approach not only makes it easy to exclude trashed content from search results but also keeps our codebase clean and efficient, without runtime reflection overhead.
This solution can be adapted to any custom page type that inherits from SitePageData
, making it versatile for projects that need better control over search visibility in Optimizely CMS.
P.S. Any input or alternative proposals would be gratefully received.
If you don't want to use Reflection then you can use following code -
I do not understand what scenario this is needed. Out of the box, the search index is automatically updated when you move content to the trash. In what cases does this not happen?
I tested right now, with latest version. When a page is deleted:
Also, if your trash has correct access rights, the RolesWithReadAccess property should be updated accordingly so that unauthenticated users (without trash-access) should not se the deleted content if you use .FilterForVisitor().
We've encountered an issue where CMS pages moved to the trash are still appearing in Find query results, even when using filters like
.ExcludeDeleted()
,.FilterForVisitor()
,.FilterOnCurrentSite()
, and.CustomFilterForVisitor()
. This leads to 404 errors when users click on these results.We've observed that when content is moved to the trash, theIsDelete
property in the Find Index isn't being updated.To address this issue, we've had to implement this custom solution to manually update the
IsDelete
field in the Find Index. This problem seems to have emerged in CMS versions 10 and above for us.Could you please provide more specific information about where and how the
IsDelete
property value is updated in the Find Index?I just confirmed using this GUI. Using my site, and the latest CMS 12 and Search & Navigation 16, the three properties I mentioned earlier (IsDeleted, Deleted, Ancestors) are updated when the content is moved to trash. Unfortunately I have no more specific details, other then what I observe here.
It could be an issue with older versions, perhaps.
It is perplexing that we are encountering this issue on CMS 12.31 and Search & Navigation 16.3. We anticipated that the
IsDelete
property would be updated accordingly when content is moved to the trash. As a temporary measure, we will implement our custom solution while concurrently investigating the underlying reason for this unexpected behavior.Thanks