Require or Prevent Publishing Content if it's in a Project in Optimizely CMS 12

I've recently been working on an Optimizely CMS 12 solution for a customer that creates and publishes a large amount of content during certain times of the year. They typically create all of the content changes and track them within an Project in the CMS, then publish all the changes at once, which is a great benefit of Optimizely's Project feature.

Recently, a question came up about this: "Is there a way we can enforce content to be part of a Project so it can be published?" This feature is more about change control than general functionality. The customer wants to make sure a change to content doesn't get published prior all other content being ready and published.

In the post, I'd like to share an example on how this could be accomplished; how you can require content to be part of a Project in order for it to be published, or on the opposite side, prevent content from being published if it is part of a Project.

While I did build this functionality for a CMS 12 solution (using .NET 5), I will note I don't see why this couldn't used in a CMS 11 solution as well. From what I'm aware, there is nothing specific to .NET 5. That said, I have not tested this on CMS 11.

A Proof of Concept

My initial thought on how this could be accomplished, without going into great lengths to modify the Optimizely CMS UI functionality, is to use content type validation. The Optimizely documentation provides a great hint on where to start.

Typically, validation is more centered around content type properties: you want to make sure a property is required, or a property's value is in a certain numerical range, or the value in a text field is an email address. This is fairly trivial with .NET's out-of-the-box validation attributes, and even custom validation attributes are not complicated to create.

For validation on the content type level, Optimizely provides the IValidate<T> interface. Any class that inherits and implements this interface is automatically registered within system to perform additional validation for those objects (the objects defined through the generic <T>).

Better yet, there's another interface that extends IValidate<T>: IContentSaveValidate<TContent>. This interface both forces us to target objects that inherit from IContentData, and also adds more contextual information, such as which SaveAction was used. This is a perfect place to start.

RequireProjectPublish

Since I want a bit of control on what content types should be validated, I first created a simple attribute to decorate our content type classes:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class RequireProjectPublishAttribute : Attribute
{
}

The related validator will use this attribute and only continue with validation if the attribute is present.

public class RequireProjectPublishValidator : IContentSaveValidate<IContent>
{
    private readonly ProjectRepository _projectRepository;

    public RequireProjectPublishValidator(ProjectRepository projectRepository)
    {
        _projectRepository = projectRepository;
    }

    public IEnumerable<ValidationError> Validate(IContent content, ContentSaveValidationContext context)
    {
        if (context.SaveAction != SaveAction.Publish)
            return Enumerable.Empty<ValidationError>();

        var attribute = content.GetType().BaseType.GetCustomAttribute<RequireProjectPublishAttribute>(false);

        if (attribute == null)
            return Enumerable.Empty<ValidationError>();

        var projectItems = _projectRepository.GetItems(new[] { content.ContentLink });

        if (projectItems == null || !projectItems.Any())
        {
            var validationError = new ValidationError()
            {
                ErrorMessage = "This content version must be part of a Project in order for it to be published.",
                Severity = ValidationErrorSeverity.Error
            };

            return new List<ValidationError>() { validationError };
        }

        return Enumerable.Empty<ValidationError>();
    }
}

Let's break this down:

  • We first inherit IContentSaveValidate, and our object is IContent (which inherits from IContentData). We need to use IContent, because it gives us access to the ContentLink, which we use to see if the content type instance is part of a Project.
  • We bring in the ProjectRepository service using constructor-based dependency injection. This service is how we'll check to see if the content type instance is part of a Project.
  • Since IContentSaveValidate gives us access to the SaveAction, we check to see if the content type is trying to be published.
  • We then see if the content type has our [RequireProjectPublish] attribute, because we only want to validate on those content types with the attribute.
  • Finally, we use the ProjectRepository to get all ProjectItem instances with our ContentLink. One thing to note here is the repository specifically looks for both the content ID and the content WorkID. This ensures we're only validating the current version of the content type instance.
  • If we don't see the current content version in a project, we return an error back to the user, along with a friendly message.

PreventProjectPublish

If we want to inverse the above functionality, it's pretty straightforward.

We use a different attribute:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class PreventProjectPublishAttribute : Attribute
{
}

And a different validator, with slightly different logic:

public class PreventProjectPublishValidator : IContentSaveValidate<IContent>
{
    private readonly ProjectRepository _projectRepository;

    public PreventProjectPublishValidator(ProjectRepository projectRepository)
    {
        _projectRepository = projectRepository;
    }

    public IEnumerable<ValidationError> Validate(IContent content, ContentSaveValidationContext context)
    {
        if (context.SaveAction != SaveAction.Publish)
            return Enumerable.Empty<ValidationError>();

        var attribute = content.GetType().BaseType.GetCustomAttribute<PreventProjectPublishAttribute>(false);

        if (attribute == null)
            return Enumerable.Empty<ValidationError>();

        var projectItems = _projectRepository.GetItems(new[] { content.ContentLink });

        if (projectItems == null || !projectItems.Any())
            return Enumerable.Empty<ValidationError>();

        var validationError = new ValidationError()
        {
            ErrorMessage = "This content version must not be part of Project in order for it to be published.",
            Severity = ValidationErrorSeverity.Error
        };

        return new List<ValidationError>() { validationError };
    }
}

This validator essentially only allows you to publish the content version if it's outside the context of a Project.

Using the Validator

To use these validators, it's really simple... just decorate the content type class. For example if we add the [RequireProjectPublish] attribute to a content type class:

[ContentType(DisplayName = "Standard Page", GUID = "00000000-0000-0000-0000-000000000000")]
[RequireProjectPublish]
public class StandardPage : PageData
{
}

Then, when you try to publish a page outside of a Project, you'll see this error message:

Edit mode error

Maybe a Better Option?

Is this the best way to handle this feature? Really, I'm not sure. I did notice some oddities with the CMS UI while playing around with this. There are some cases when after you click the "Publish" button and see the error, the "Publish" button disappears, making it look it did publish. You need to refresh the browser to see it didn't publish. Maybe this is a bug within the platform?

You could probably create similar functionality with IContentEvents, specifically tapping into the IPublishingContent event, but I do like the UI-based validation messaging from this approach.

What would probably be better is if this is more out-of-the-box functionality of the CMS... maybe part of the Content Approval Sequence, or something more engrained within the Project functionality.

In any case, I'm happy with this approach, and I'll continue using it until something better comes up.