Ashish Rasal
Nov 7, 2024
  110
(0 votes)

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:

  1. Detects when content is moved to or from the trash.
  2. Sets an IsDeleted flag on custom page types that inherit from SitePageData.
  3. 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.

Nov 07, 2024

Comments

Ashish Rasal
Ashish Rasal Nov 7, 2024 06:23 AM

If you don't want to use Reflection then you can use following code -

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 supported content types with their processing logic
		var contentProcessors = new Dictionary<Type, Action<IContent, bool>>
		{
			{ typeof(StorytellingPage), (content, isDeleted) => ProcessContent<StorytellingPage>(content, isDeleted) },
			{ typeof(BlogDetailPage), (content, isDeleted) => ProcessContent<BlogDetailPage>(content, isDeleted) },
			{ typeof(PressReleaseDetailPage), (content, isDeleted) => ProcessContent<PressReleaseDetailPage>(content, isDeleted) },
			{ typeof(NewsletterDetailPage), (content, isDeleted) => ProcessContent<NewsletterDetailPage>(content, isDeleted) }
		};

		// 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)
		{
			// Get the actual base type of the content to match the type in the dictionary
			var contentType = e.Content.GetType().BaseType;

			// Try to get the processor for the specific content type
			if (contentProcessors.TryGetValue(contentType, out var processor))
			{
				// Execute the processor, setting IsDeleted to true (for deletion) or false (for restore)
				processor.Invoke(e.Content, isDeleted);
			}
		}
	}
}

// Generic method to handle content processing: update, save, and index the content in Find
private void ProcessContent<T>(IContent content, bool isDeleted) where T : PageData
{
	var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();

	// Retrieve content as the specific type T and create a writable clone
	if (contentRepository.Get<T>(content.ContentLink).CreateWritableClone() is T writableContent)
	{
		// Check and update the IsDeleted property using reflection (applies to any type with this property)
		var isDeletedProperty = typeof(T).GetProperty("IsDeleted");
		if (isDeletedProperty != null)
		{
			// Set the IsDeleted property to the provided value
			isDeletedProperty.SetValue(writableContent, isDeleted);

			// Save the updated content without validation
			contentRepository.Save(writableContent, SaveAction.SkipValidation, AccessLevel.NoAccess);

			// Index the updated content in Optimizely Find
			_findClient.Index(writableContent);
		}
	}
}

Tomas Hensrud Gulla
Tomas Hensrud Gulla Nov 7, 2024 07:52 AM

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:

  • IsDeleted is set to true
  • Deleted is set to the time the content eas deleted
  • Ancestors is updated and will now containt the id of trash (+ root)

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().

Ashish Rasal
Ashish Rasal Nov 7, 2024 09:34 AM

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, the IsDelete 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?

Tomas Hensrud Gulla
Tomas Hensrud Gulla Nov 7, 2024 09:51 AM

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.

Ashish Rasal
Ashish Rasal Nov 7, 2024 10:01 AM

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

Please login to comment.
Latest blogs
Recraft's image generation with AI-Assistant for Optimizely

Recraft V3 model is outperforming all other models in the image generation space and we are happy to share: Recraft's new model is now available fo...

Luc Gosso (MVP) | Nov 8, 2024 | Syndicated blog

CMS + CMP + Graph integration

We have just released a new package https://nuget.optimizely.com/package/?id=EPiServer.Cms.WelcomeIntegration.Graph which changes the way CMS fetch...

Bartosz Sekula | Nov 5, 2024

Block type selection doesn't work

Imagine you're trying to create a new block in a specific content area. You click the "Create" link, expecting to see a CMS modal with a list of...

Damian Smutek | Nov 4, 2024 | Syndicated blog