Feature switching based on language branch
Look after a multi-lingual site for long enough and there will come a point where you need to roll out some functionality one language or region at a time. At the recent developer meetup in London I coughed my way through presenting a technique for doing just that.
The idea came from a client requirement where they have a multi-lingual site run by local teams in those markets. Their requirement involved a fairly extensive third party integration but, because of the nature of that integration it needed to be rolled out one market at a time. From the CMS side, we needed to add a number of fields to a number of our content types and ultimately provide a checkbox which determines whether the new integration is displayed on the site.
In theory that's pretty straightforward but it’s not the best editor experience to have a bunch of fields which won’t actually do anything if you’re not in one of the chosen languages the functionality has been rolled out to. My first thought was to create a validator which could be applied to the feature-specific fields and check whether the feature has been enabled for that language. If the feature hasn’t been enabled and an editor starts typing into one of the fields, they will be shown an error message letting them know that the functionality hasn’t been rolled out to their region. This approach is better than nothing but still isn’t the best experience for the editors as we’re still, in effect, offering them a feature they can’t have. For the best editor experience, we should only show the feature-specific fields if that feature is enabled but, as all of our languages use the same content types, how can we set about doing that?
My initial assumption was that, if we need to make a change to the way the Episerver editor displays a field, we’d need to do something with dojo and, quite frankly, every time it looks like I’ll need to write some dojo my heart sinks. In this instance however I found an alternative.
When looking into the validator approach I noticed that, on a validation attribute (or, in fact, any attribute), you can implement the IMetadataAware interface which requires an additional method called OnMetadataCreated. What this method does is to allow us to modify the settings which tell the Episerver editor UI how to display a given field before the field is rendered and, perhaps most interestingly in this case, gives us the option to mark the field as read-only or to hide it from view altogether.
So, now onto some code. In order to keep things simple, the code below is a kind of simplified version of what was produced, built on the alloy site which you should all be familiar with. In the code below, our integration is a twitter widget which requires us to set a heading and an account to pull the feed from.
First up we need a mechanism to determine whether the integration is available in a given language and there are many ways to do this. If you’re after a more comprehensive feature switching library, check out this project from Valdis but, for the sake of simplicity, in the example below I’ve just used a Boolean field in a tab called “features” on the start page of the site. The reason for the special tab is twofold. First, it allows us some scope for extending this functionality to other features in future – we just need to add an additional Boolean field for each feature and they’re all grouped together. The other reason is so we can apply permissions to the tab to stop local editors from enabling features they shouldn’t have access to.
[Display(Name = "Activate Twitter Functionality", GroupName = Global.GroupNames.Features)]
[CultureSpecific]
public virtual bool TwitterFeedActive { get; set; }
Next we create an attribute implementing IMetaDataAware which accepts the name of a feature, checks to see whether the feature is enabled in the current language and, if it’s not, modifies the field’s configuration to hide it from the editor.
public class FeatureSwitch : Attribute, IMetadataAware
{
private string _featureName;
private IContentLoader _contentLoader;
public bool AllowEditing
{
get
{
if (!_contentLoader.TryGet(ContentReference.StartPage, out StartPage startPage))
{
return false;
}
//Check whether our feature switch property exists and has been checked on the start page
return startPage.Property.Any(x => x.Name.Equals($"{_featureName}Active"))
&& ((startPage.Property[$"{_featureName}Active"]?.Value as bool?) ?? false);
}
}
public FeatureSwitch(string featureName)
{
_featureName = featureName;
_contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
}
public void OnMetadataCreated(ModelMetadata metadata)
{
metadata.ShowForEdit = AllowEditing;
}
}
Finally, we add the validation attribute to all relevant fields, specifying the feature they relate to.
[Display(
Name = "Twitter Feed Heading",
GroupName = SystemTabNames.Content,
Order = 5)]
[CultureSpecific]
[FeatureSwitch("TwitterFeed")]
public virtual string TwitterFeedHeading { get; set; }
[Display(
Name = "Twitter Account",
GroupName = SystemTabNames.Content,
Order = 6)]
[CultureSpecific]
[FeatureSwitch("TwitterFeed")]
public virtual string TwitterAccount { get; set; }
And there we have it, an alloy site with the fields to set up a twitter feed on the EN site but not on the SV site.
Taking this a step further, it may not just be individual properties we want to limit to a specific language, it may be content types as well. Continuing the twitter feed example from above, you may want to add a specific block type to represent the twitter feed rather than just properties on a specific page type so how can we extend this approach to do that as well?
If we have individual teams managing content in each language then we could use permissions on the content types to limit who can create content of that type however this doesn’t allow for scenarios where a given individual works in multiple languages. It’s also a second place for us to make changes which feels unnecessary given that we’ve already got a feature switch defined on our start page.
The better option here is to tie in to the ContentTypeAvailabilityService which is responsible for providing the listings of content types we can use when we click to create a new content item. By taking this approach we can make use of our existing FeatureSwitch attribute.
We could create our own implementation of the abstract ContentTypeAvailabilityService class but that would involve writing our own version of the existing logic used to retrieve and filter the content types which seems like a lot of work just to filter the list of content types. We could also consider inheriting from the DefaultContentTypeAvailabilityService and just overriding the bits we need but that is found in an internal namespace and so is prone to breaking changes without warning so, instead I’m going to intercept whichever implementation of the ContentTypeAvailabilityService is being used and add the filtering on top.
First, we need to create a class inheriting from ContentTypeAvailabilityService with a constructor accepting the currently registered instance. Next, in the ListAvailable methods, we take the list generated by the registered ContentTypeAvailabilityService instance and loop through the available content types, checking for the FeatureSwitch attribute. If we find a content type with that attribute, we check the AllowEditing property on the attribute and, if it’s false, we remove the type from the list.
public class CustomContentTypeAvailabilityService : ContentTypeAvailabilityService
{
private readonly ContentTypeAvailabilityService _defaultContentTypeAvailabilityService;
public CustomContentTypeAvailabilityService(ContentTypeAvailabilityService defaultContentTypeAvailabilityService)
{
_defaultContentTypeAvailabilityService = defaultContentTypeAvailabilityService;
}
public override AvailableSetting GetSetting(string contentTypeName)
{
return _defaultContentTypeAvailabilityService.GetSetting(contentTypeName);
}
public override bool IsAllowed(string parentContentTypeName, string childContentTypeName)
{
return _defaultContentTypeAvailabilityService.IsAllowed(parentContentTypeName, childContentTypeName);
}
public override IList<ContentType> ListAvailable(string contentTypeName, IPrincipal user)
{
return FilterAvailableList(_defaultContentTypeAvailabilityService.ListAvailable(contentTypeName, user));
}
public override IList<ContentType> ListAvailable(IContent content, bool contentFolder, IPrincipal user)
{
return FilterAvailableList(_defaultContentTypeAvailabilityService.ListAvailable(content, contentFolder, user));
}
private IList<ContentType> FilterAvailableList(IList<ContentType> list)
{
for (var i = list.Count-1; i >= 0; i--)
{
var contentType = list[i];
var attributes = contentType.ModelType.GetCustomAttributes(true) ?? new object[0];
foreach (var attribute in attributes)
{
var featureAttribute = attribute as FeatureSwitch;
if (featureAttribute != null && !featureAttribute.AllowEditing)
{
list.RemoveAt(i);
}
}
}
return list;
}
}
Next, we need to register our class to intercept the existing ContentTypeAvailabilityService implementation. We can do this in an initialisation module as follows.
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ContentTypeAvailabilityInitialization : IConfigurableModule
{
public void ConfigureContainer(ServiceConfigurationContext context)
{
context.Services.Intercept<ContentTypeAvailabilityService>(
(locator, defaultContentTypeAvailabilityService) => new CustomContentTypeAvailabilityService(defaultContentTypeAvailabilityService));
}
public void Initialize(InitializationEngine context) { }
public void Uninitialize(InitializationEngine context) { }
}
Then finally we can mark some content types in the same way as we marked our properties and verify that they only appear on languages where that feature is enabled.
/// <summary>
/// Used to add a twitter feed
/// </summary>
[SiteContentType(GUID = "D8C3E587-AA5D-4456-8BC7-47CF971CC835")]
[SiteImageUrl]
[FeatureSwitch("TwitterFeed")]
public class TwitterFeedBlock : SiteBlockData
{
[Display(
Name = "Twitter Feed Heading",
GroupName = SystemTabNames.Content,
Order = 5)]
[CultureSpecific]
public virtual string TwitterFeedHeading { get; set; }
[Display(
Name = "Twitter Account",
GroupName = SystemTabNames.Content,
Order = 6)]
[CultureSpecific]
public virtual string TwitterAccount { get; set; }
}
As a final note, this technique isn’t limited to just restricting by language, you could easily use the same approach to handle multi-site scenarios, differing editor permissions or even more outlandish requirements like “we should only be able to add blog articles on a Friday afternoon”. Whatever the requirements, I hope you find it useful.
Really cool, definitely see the potential here!
I started loving this Attribute feature. You can do anything you want. Thanks Paul for beautiful post.