A feature could be a Page or a Block or something similar, you get the idea. They live in separate projects.
Since all features have their own project, they don't know about each other.
This structure allows us to cherry pick features when creating a new site by simply adding/removing stuff from our packages.config. It's pretty sweet.
There's a problem though. Since our features doesn't know about each other, we can't use the AllowedTypes/AvailableContentTypes attributes. Or can we?
I had a discussion with a colleague at work about how to solve this, this is what we came up with, kind of. :)
We're going to focus on the MediaPage
. It looks like this:
[ContentType(DisplayName = "Media Page", GUID = "5b393025-d856-4392-aa99-61f1a468ea79", Description = "A page listing media.")]
public class MediaPage : PageData
{
[AllowedTypes(AllowedTypes = new [] {typeof(VideoBlock)})]
public virtual ContentArea ContentArea { get; set; }
}
The AvailableContentTypes
problem.
We want to restrict which pagetypes the editors can create under the MediaPage
. We only want them to be able to create pages of the CoolPage
type below our MediaPages
.
If our features knew about each other we would simply do it like this:
[ContentType(DisplayName = "Media Page", GUID = "5b393025-d856-4392-aa99-61f1a468ea79", Description = "A page listing media.")]
[AvailableContentTypes(Include = new []{typeof(CoolPage)})]
public class MediaPage : PageData
{
[AllowedTypes(AllowedTypes = new [] {typeof(VideoBlock)})]
public virtual ContentArea ContentArea { get; set; }
}
But since our features doesn't know about each other, we can't do that.
The AvailableContentTypes
solution.
Note: All files for this solution can be found on GitHub.
I did some reflection and looked for usages of the AvailableContentTypes
attribute. I found a repository named AvailableModelSettingsRepository
. That repository contains the following public virtual methods:
void RegisterSetting(Type model, IContentTypeAvailableModelSetting modelSetting)
IContentTypeAvailableModelSetting GetSetting(Type model)
ContentTypeAvailableModelSetting GetRuntimeSetting(Type model)
IDictionary<Type, ContentTypeAvailableModelSetting> ListRuntimeSettings()
The RegisterSetting
method takes a IContentTypeAvailableModelSetting
and saves it. This looks like a good method to override and add some custom logic. I did just that and implemented some of the private classes that the method used, but I just couldn't get it to work(my custom modelSetting got ignored). I got really tired of fiddling with that method so I changed my approach.
Meet ListRuntimeSettings
.
When going into Edit mode in EPiServer, this method gets called once and returns a list of all registered ContentTypes and their AvailableModelSettings.
To solve my problem, I needed to somehow modify that list and add my own AvailableModelSettings for specific ContentTypes. This is how I did it.
I created a InjectedAvailableModelSettingsRepository
which inherits from AvailableModelSettingsRepository
.
I then created the following method
public override IDictionary<Type, ContentTypeAvailableModelSetting> ListRuntimeSettings()
{
var runtimeSettings = base.ListRuntimeSettings();
foreach (var customSetting in CustomSettings)
{
if (runtimeSettings.ContainsKey(customSetting.Key))
{
var merged = MergeSettings(customSetting.Value, runtimeSettings[customSetting.Key]);
runtimeSettings[customSetting.Key] = merged;
}
}
return runtimeSettings;
}
The method calls the base implementation and then modifies the return value before returning it.
CustomSettings
is a static field looking like this:
private static readonly Dictionary<Type, ContentTypeAvailableModelSetting> CustomSettings = InjectedAvailableModelSettings.GetCustomAvailableModelSettings();
The MergeSettings
method looks like this:
private ContentTypeAvailableModelSetting MergeSettings(ContentTypeAvailableModelSetting customSetting, ContentTypeAvailableModelSetting runtimeSetting)
{
var mergedSetting = new ContentTypeAvailableModelSetting();
mergedSetting.Excluded = new HashSet<Type>(customSetting.Excluded.Concat(runtimeSetting.Excluded).Distinct());
mergedSetting.Included = new HashSet<Type>(customSetting.Included.Concat(runtimeSetting.Included).Distinct());
mergedSetting.IncludedOn = new HashSet<Type>(customSetting.IncludedOn.Concat(runtimeSetting.IncludedOn).Distinct());
mergedSetting.Availability = customSetting.Availability;
return mergedSetting;
}
InjectedAvailableModelSettings
is a static class where all custom "injection" of AvailableModelSettings happens, it looks like this:
public static class InjectedAvailableModelSettings
{
public static Dictionary<Type, ContentTypeAvailableModelSetting> GetCustomAvailableModelSettings()
{
var mappedModelSettings = MappedModelSettings();
return mappedModelSettings;
}
private static Dictionary<Type, ContentTypeAvailableModelSetting> MappedModelSettings()
{
return new Dictionary<Type, ContentTypeAvailableModelSetting>
{
{
typeof(MediaPage) , new ContentTypeAvailableModelSetting
{
Availability = Availability.Specific,
Included = new HashSet<Type> {typeof(CoolPage) }
}
}
};
}
}
In the MappedModelSettings you specify which type(MediaPage in this case) and how the ContentTypeAvailableModelSetting should look like.
Since we wanted to restrict the pagetypes under the MediaPage to only allow the CoolPage
type we set the Included property to new HashSet<Type>{typeof(CoolPage)}
And that's pretty much it, only thing left to do is replacing the AvailableModelSettingsRepository
with our own implementation.
The following code is added to the ConfigureContainer
method in the DependencyResolverInitialization
file in the Alloy project.
container.For<IAvailableModelSettingsRepository>().Use<InjectedAvailableModelSettingsRepository>();
If we now try to create a new page under a MediaPage in edit mode it will look like this
instead of this
SUCCESS!
The AllowedTypes
problem.
Note: If there's a simpler/cleaner way of doing this, please let me know :).
We are using the AllowedTypes
attribute to restrict what the editors can add to the ContentArea, we are only allowing the VideoBlock
type in the ContentArea.
Now the editors wants to add MusicBlocks
as well, can you fix that Josef? Quickfix, right?
Sure, let's just add the MusicBlock
type to the AllowedTypes like this:
[ContentType(DisplayName = "Media Page", GUID = "5b393025-d856-4392-aa99-61f1a468ea79", Description = "A page listing media.")]
public class MediaPage : PageData
{
[AllowedTypes(AllowedTypes = new [] {typeof(VideoBlock), typeof(MediaBlock)})]
public virtual ContentArea ContentArea { get; set; }
}
but...that wont work without adding a reference to the Feature.MusicBlock
. And we don't want to do that since our features should be isolated from each other.
So, let's fix the problem.
The AllowedTypes
solution.
Note: All code for this section can be found on Github
I did some decompiling(and some praying) and looked for usages of the AllowedTypes
attribute. I found the class ContentDataAttributeScanningAssigner
and the method AssignValuesToPropertyDefinition
.
It looks like this(decompiled so it looks a bit...odd):
public virtual void AssignValuesToPropertyDefinition(PropertyDefinitionModel propertyDefinitionModel, PropertyInfo property, ContentTypeModel parentModel)
{
...//Some code above, not important
foreach (Attribute attribute1 in Attribute.GetCustomAttributes((MemberInfo) property, true))
{
...//Some code above not important
var attribute2 = attribute1 as AllowedTypesAttribute;
if (attribute2 != null)
{
VerifyAllowedTypesAttribute(attribute2, property);
}
...//More code below not important
propertyDefinitionModel.Attributes.AddAttribute(attribute1);
}
}
The code loops through all CustomAttributes and does some type checking. If the attribute is a AllowedTypesAttribute
it will call the method VerifyAllowedTypesAttribute
. That method just validates that the type that the attribute is placed on is of a valid type(ContentReference
, ContentArea
or IEnumerable<ContentReference>
) and also that the types specified in the AllowedTypes
and RestrictedTypes
on the AllowedTypesAttribute
are valid(needs to inherit from IContent
).
It will then add the attribute to the propertyDefinitionModel.Attributes.
Im interested in altering the attribute before it gets added to the propertyDefinition.
The AssignValuesToPropertyDefinition
method is both public and virtual, so it looks like a good method to ATTACK.
I created the following class(just scroll through it, I will break it down further down):
Full file: InjectedContentDataAttributeScanningAssigner
public class InjectedContentDataAttributeScanningAssigner : ContentDataAttributeScanningAssigner
{
/// <summary>
/// Almost exact implementation of the AssignValuesToPropertyDefinition in the ContentDataAttributeScanningAssigner
/// the only thing that differs is the added call to CustomAllowedTypes.GetMergedAllowedTypesAttribute.
/// That call allows us to add more types to the Allowed/RestricedTypes without using the AllowedTypes attribute.
/// </summary>
/// <param name="propertyDefinitionModel"></param>
/// <param name="property"></param>
/// <param name="parentModel"></param>
public override void AssignValuesToPropertyDefinition(PropertyDefinitionModel propertyDefinitionModel, PropertyInfo property, ContentTypeModel parentModel)
{
if (property.IsAutoGenerated() && !property.IsAutoVirtualPublic())
{
var exceptionMessage = string.Format(CultureInfo.InvariantCulture,
"The property '{0}' on the content type '{1}' is autogenerated but not virtual declared.",
property.Name, property.DeclaringType.Name);
throw new InvalidOperationException(exceptionMessage);
}
//This is our added logic to merge a predefined AllowedTypes attribute with our own AllowedTypes specified in code.
#region InjectedAllowedTypes
var customAttributes = Attribute.GetCustomAttributes(property, true).ToList();
if (customAttributes.Any(x => x is AllowedTypesAttribute))
{
var existingAllowedTypesAttribute =
customAttributes.FirstOrDefault(x => x is AllowedTypesAttribute) as AllowedTypesAttribute;
var mergedAllowedTypesAttribute = InjectedAllowedTypes.GetMergedAllowedTypesAttribute(existingAllowedTypesAttribute, parentModel, property);
customAttributes.Remove(existingAllowedTypesAttribute);
customAttributes.Add(mergedAllowedTypesAttribute);
}
else
{
var mergedAllowedTypesAttribute = InjectedAllowedTypes.GetMergedAllowedTypesAttribute(null, parentModel, property);
if (mergedAllowedTypesAttribute != null)
{
customAttributes.Add(mergedAllowedTypesAttribute);
}
}
#endregion
foreach (var attribute in customAttributes)
{
if (attribute is BackingTypeAttribute)
{
var backingTypeAttribute = attribute as BackingTypeAttribute;
if (backingTypeAttribute.BackingType != null)
{
if (!typeof(PropertyData).IsAssignableFrom(backingTypeAttribute.BackingType))
{
var exceptionMessage = string.Format(CultureInfo.InvariantCulture,
"The backing type '{0}' attributed to the property '{1}' on '{2}' does not inherit PropertyData.",
backingTypeAttribute.BackingType.FullName, property.Name, property.DeclaringType.Name);
throw new TypeMismatchException(exceptionMessage);
}
if (property.IsAutoVirtualPublic())
{
ValidateTypeCompability(property, backingTypeAttribute.BackingType);
}
}
propertyDefinitionModel.BackingType = backingTypeAttribute.BackingType;
}
else if (attribute is AllowedTypesAttribute)
{
var allowedTypesAttribute = attribute as AllowedTypesAttribute;
VerifyAllowedTypesAttribute(allowedTypesAttribute, property);
}
else if (attribute is DisplayAttribute)
{
var displayAttribute = attribute as DisplayAttribute;
propertyDefinitionModel.DisplayName = displayAttribute.GetName();
propertyDefinitionModel.Description = displayAttribute.GetDescription();
propertyDefinitionModel.Order = displayAttribute.GetOrder();
propertyDefinitionModel.TabName = displayAttribute.GetGroupName();
}
else if (attribute is ScaffoldColumnAttribute)
{
var scaffoldColumnAttribute = attribute as ScaffoldColumnAttribute;
propertyDefinitionModel.AvailableInEditMode = scaffoldColumnAttribute.Scaffold;
}
else if (attribute is CultureSpecificAttribute)
{
var specificAttribute = attribute as CultureSpecificAttribute;
ThrowIfBlockProperty(specificAttribute, property);
propertyDefinitionModel.CultureSpecific = specificAttribute.IsCultureSpecific;
}
else if (attribute is RequiredAttribute)
{
var requiredAttribute = attribute as RequiredAttribute;
ThrowIfBlockProperty(requiredAttribute, property);
propertyDefinitionModel.Required = true;
}
else if (attribute is SearchableAttribute)
{
var searchableAttribute = attribute as SearchableAttribute;
ThrowIfBlockProperty(searchableAttribute, property);
propertyDefinitionModel.Searchable = searchableAttribute.IsSearchable;
}
else if (attribute is UIHintAttribute)
{
var uiHintAttribute = attribute as UIHintAttribute;
if (!string.IsNullOrEmpty(uiHintAttribute.UIHint))
{
if (string.Equals(uiHintAttribute.PresentationLayer, "website"))
{
propertyDefinitionModel.TemplateHint = uiHintAttribute.UIHint;
}
else if (string.IsNullOrEmpty(uiHintAttribute.PresentationLayer) &&
string.IsNullOrEmpty(propertyDefinitionModel.TemplateHint))
{
propertyDefinitionModel.TemplateHint = uiHintAttribute.UIHint;
}
}
}
propertyDefinitionModel.Attributes.AddAttribute(attribute);
}
}
//A bunch of private helper functions below just calling the parent class with reflection. Check the gist to see all code.
}
The AssignValuesToPropertyDefinition
is overridden and does the exact same thing as the base method except for one thing.
I've added some code before the looping of the CustomAttributes. Im looking for the AllowedTypesAttribute
. If found, I'll replace it with a merged one specified in GetMergedAllowedTypesAttribute
.
If not found, I'll still look in the GetMergedAllowedTypesAttribute
and add a new AllowedTypes
attribute if I've specified anything for that particular contenttype/property.
...//More code above
//This is our added logic to merge a predefined AllowedTypes attribute with our own AllowedTypes specified in code.
#region InjectedAllowedTypes
var customAttributes = Attribute.GetCustomAttributes(property, true).ToList();
if (customAttributes.Any(x => x is AllowedTypesAttribute))
{
var existingAllowedTypesAttribute = customAttributes.FirstOrDefault(x => x is AllowedTypesAttribute) as AllowedTypesAttribute;
var mergedAllowedTypesAttribute = InjectedAllowedTypes.GetMergedAllowedTypesAttribute(existingAllowedTypesAttribute, parentModel, property);
customAttributes.Remove(existingAllowedTypesAttribute);
customAttributes.Add(mergedAllowedTypesAttribute);
}
else
{
var mergedAllowedTypesAttribute = InjectedAllowedTypes.GetMergedAllowedTypesAttribute(null, parentModel, property);
if (mergedAllowedTypesAttribute != null)
{
customAttributes.Add(mergedAllowedTypesAttribute);
}
}
#endregion
foreach (var attribute in customAttributes)
....//More code below
The method InjectedAllowedTypes.GetMergedAllowedTypesAttribute
looks for a AllowedTypesAttribute
specified in that file, if found, it will merge it with the existing attribute and then return it.
The class looks like this:
public static class InjectedAllowedTypes
{
public static AllowedTypesAttribute GetMergedAllowedTypesAttribute(AllowedTypesAttribute allowedTypesAttribute, ContentTypeModel contentTypeModel, PropertyInfo property)
{
var allCustomAttributes = GetCustomAllowedTypesAttributes();
var key = string.Format("{0}.{1}", contentTypeModel.ModelType.Name, property.Name);
var customAttributeForType = allCustomAttributes.FirstOrDefault(x => x.Key == key);
if (customAttributeForType.Value == null)
{
return allowedTypesAttribute;
}
var existingAllowedTypes = allowedTypesAttribute != null ? allowedTypesAttribute.AllowedTypes : new Type[] {};
var existingRestrictedTypes = allowedTypesAttribute != null ? allowedTypesAttribute.RestrictedTypes : new Type[] {};
var mergedAllowedTypesAttribute = new AllowedTypesAttribute
{
AllowedTypes = existingAllowedTypes.Concat(customAttributeForType.Value.AllowedTypes).Distinct().ToArray(),
RestrictedTypes = existingRestrictedTypes.Concat(customAttributeForType.Value.RestrictedTypes).Distinct().ToArray()
};
//It seems like EPiServer adds IContentData automatically, so we remove that one if we have a custom "attribute" value for the AllowedTypes.
if (customAttributeForType.Value.AllowedTypes.Any())
{
mergedAllowedTypesAttribute.AllowedTypes =
mergedAllowedTypesAttribute.AllowedTypes.Where(x => x != typeof (IContentData)).ToArray();
}
return mergedAllowedTypesAttribute;
}
private static Dictionary<string, AllowedTypesAttribute> GetCustomAllowedTypesAttributes()
{
return new Dictionary<string, AllowedTypesAttribute>
{
{
string.Format("{0}.{1}",typeof(MediaPage).Name, "ContentArea"), new AllowedTypesAttribute
{
AllowedTypes = new[] {typeof (MusicBlock)}
}
}
};
}
}
In the GetCustomAllowedTypesAttributes
I'm specifying that the property ContentArea on the MediaPage should have MusicBlock as an AllowedType. That value will then be merged with the already existing value on the ContentArea property(set with the AllowedTypesAttribute) and then returned to the AssignValuesToPropertyDefinition where it will get saved.
The only thing left to do now is replacing the ContentTypeModelAssigner with my own implementation. In the Alloy project it's easy to do, just add the following in the ConfigureContainer method in the DependencyResolverInitialization.cs file
container.For<IContentTypeModelAssigner>().Use<InjectedContentDataAttributeScanningAssigner>();
If we now load up the MediaPage in Edit mode it will look like this when trying to add blocks to the contentarea:
Success!
Some notes.
It sucked that I needed to override the whole AssignValuesToPropertyDefinition
method, it would be nice if there were a public virtual SetAllowedTypesAttributes(AllowedTypesAttributes attribute)
or something similar.
I also needed to create my own implementation of IsAutoGenerated
and IsAutoVirtualPublic
since they where marked Internal. I just copied the existing implementation like this:
using System;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace ModularAllowedTypes
{
public static class PropertyInfoExtensions
{
public static bool IsAutoGenerated(this PropertyInfo p)
{
if (p.GetGetMethod() != null && p.GetSetMethod() != null && p.GetGetMethod().GetCustomAttributes(typeof(CompilerGeneratedAttribute), false).Length == 1)
return p.GetSetMethod().GetCustomAttributes(typeof(CompilerGeneratedAttribute), false).Length == 1;
return false;
}
public static bool IsAutoVirtualPublic(this PropertyInfo self)
{
if (self == null)
throw new ArgumentNullException("self");
if (self.IsAutoGenerated())
return (self.GetAccessors(true)).All(m =>
{
if (m.IsVirtual)
return m.IsPublic;
return false;
});
return false;
}
}
}