Pages

Tuesday, October 2, 2018

DefaultValue in PropertyList

When Episerver brought out PropertyList support in 9.0 and showed the world how to utilize it (read the article here) it rocked the Episerver developer community and changed the way we utilize the CMS to this day! Okay, that's obviously an exaggeration, but it did introduce a different property type to the community, and brought about a different way of supporting lists or collections of data, that didn't require a bunch of blocks added to a ContentArea.

As interesting as it is, however, there are some shortcomings to this functionality. After all, it's mentioned in the linked article that it is a "pre-release API that is UNSTABLE." It's expected to have some quirks and shortcomings. Thankfully, as has already been demonstrated by Grzegorz, in his PropertyList with Images article, the PropertyList, or more importantly the CollectionEditor, can be extended to modify the functionality to fit different needs.

In this article I am taking a similar approach to Grzegorz to extend the functionality, but instead of supporting images in the list, I needed to support a default value specified through code.

The Issue

I was assisting with a project that was making heavy use of IList<T> properties throughout the site, dealing with collections of address data, location regions, and timezones, to name a few examples. Some of these collections were being imported from an external system and had a standard value that should not be modified in the list. For that, the EditableAttribute was being used to mark them as ReadOnly in the UI. Authors can still add more to the list, however, and when they would, the field would be empty, and nothing could be entered. 


This created a less than ideal experience. In other situations, such as a Region, the values were editable, but it was expected that a default value would populate the field when a new entry was added, to enhance the authoring experience. Even though the author could add a value to the field, it was going to be the same thing 90% of the time.

The Solution

My idea to work through this scenario was to leverage the DefaultValue attribute. After adding the attribute to my properties, however, it became apparent that the CollectionEditor didn't support it, as, well, no default value showed. Support for it needed to be achieved using a similar method to the PropertyList with Images, by adding a custom Editor Descriptor class, and extending the CollectionEditor Dojo widget. 

Step 1: The Editor Descriptor

The first thing needed is the Editor Descriptor class, and to create this we need to inherit CollectionEditorDescriptor<T>, within the EPiServer.Cms.Shell.UI.ObjectEditing.EditorDescriptors namespace. The ModifyMetadata method is where the editor configuration is set, and where custom data can be set for the editor. We need to override this method to set the ClientEditingClass to point to the extended Collection Editor widget, and we will also use this method to get the default values set on any properties.
[pre class="brush:csharp"]
public class CustomCollectionEditor<T> : CollectionEditorDescriptor<T> where T : new()
{
    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
    {
        base.ModifyMetadata(metadata, attributes);

        metadata.ClientEditingClass = "app/editors/CustomCollectionEditor";
        metadata.EditorConfiguration.Add("defaultItem", getDefaultValues((metadata.ModelType.GetGenericArguments()[0]).GetProperties()));
    }
}
[/pre]
They key to getting properties with the DefaultValueAttribute is using reflection on the ExtendedMetadata. The ModelType property exposes the PropertyList type - IEnumerable<T>, IList<T> - and using the GetGenericArguments method on the type, we access the generic type T. Since there is only one generic argument for the IList/IEnumerable type, you can immediately reference the 0 index for this array. Once we have a reference to T, we use reflection to get the properties using the GetProperties method, which returns an array of PropertyInfo for our generic T, which is passed to the "getDefaultValues" method to filter them further. Slip this method into the CustomCollectionEditor class to make it work.
[pre class="brush:csharp"]
private Dictionary<string, object> getDefaultValues(IEnumerable<PropertyInfo> properties)
{
    if (!properties.Any()) { return new Dictionary<string, object>(); }

    var defaultsList = properties.Where(t => (      // first get properties that have defaultvalues
            t.GetCustomAttributes<DefaultValueAttribute>(true).Any()
        )).ToDictionary(    // then assign property name as Dict Key and the value of
            p => p.Name,        // the DefaultValueAttribute as the Dict Value
            p => p.GetCustomAttribute<DefaultValueAttribute>(true).Value
        );

    return defaultsList;
}
[/pre]
Here, the "properties" are filtered using a Linq statement, to get only those properties with a DefaultValueAttribute set on them. Each of those properties is then set into a Dictionary with the property name as the Key, and the value of the DefaultValueAttribute as the Value. This makes it serialize into a JSON object rather nicely for our Editor Widget. 

Note the name of the element being added to the EditorConfiguration in the ModifyMetadata method, since that same name becomes accessible in the Editor Widget.

Step 2: The Editor Widget

Next, it's necessary to create a new Dojo Widget, extended from the Episerver CollectionEditor so the default values can be applied. The widget will be a JS file, and should be stored in the ClientResources directory.

You can define a directory path for this that suits your conventions, but whatever it is will need to match the ClientEditingClass specified in the ModifyMetadata method, and will also need to be referenced in the Module.config file. I have my Dojo Editor widgets nested in an "Editors" folder under my "Scripts" directory.
[pre class="brush:csharp"]
define([
    "dojo/_base/array",
    "dojo/_base/declare",
    "dojo/_base/lang",
    "epi-cms/contentediting/editors/CollectionEditor"
],
    function (
        array,
        declare,
        lang,
        CollectionEditor,
        extendedFormaters
    ) {
        return declare([CollectionEditor],
        {
            _onToggleItemEditor: function (item, index) {
                if (item === null && !this._isEmptyObject(this.defaultItem)) {
                    item = lang.mixin({}, this.defaultItem);    // defaultItem is from our editor descriptor metadata
                }
                this.inherited(arguments);  // item is part of arguments
            },
            // simple function to properly test if a JavaScript object is empty
            _isEmptyObject: function (obj) {
                var name;
                for (name in obj) {
                    if (obj.hasOwnProperty(name)) {
                        return false;
                    }
                }
                return true;
            }
        });
    }
);
[/pre]
The key to keeping this simple is the last module of "define", referencing the Episerver CollectionEditor. Without going too much into how Dojo works, by referencing this, and including the reference in the "declare" statement, it's like inheriting from a class in C#. Because of this, we can "override" the _onToggleItemEditor method, which is what fires off the editor dialog when you add a new item to a CollectionEditor. This method actually resides in the _AddItemDialogMixin, which the CollectionEditor widget inherits. The "this.inherited" line is similar to C# "base.WhateverFunction(arguments).

The same editor is used for adding a new item or editing an existing one, and it does this by checking if the "item" argument is null. It also doesn't need a complete item for editing to work, so we can simply check if the "defaultItem" object is empty to know if we have default values to pass along, and because we used a Dictionary to define it with the property name as the key, it will be a simple JSON object the editor can use. Any properties not supplied will be added when the editor OK button is clicked.  

Step 3: Module.config

To make the Dojo widgets discoverable, you need to define their path for Episerver using the module.config file. If you don't already have the config file created, create a "module.config" file in the root of your website. You need to add a "dojoModules" section to the config to define where your modules can be found. 
[pre class="brush:xml"]
<?xml version="1.0" encoding="utf-8"?>
<module>
 <assemblies>
  <add assembly="Sandbox" />
  </assemblies>
  <!--define a location for storing dojo modules. the "path" value is relative to the ClientResources physical folder.
  the "name" value acts as an alias for that location.-->
  <dojoModules>
    <add name="app" path="Scripts" />
  </dojoModules>
</module>
[/pre]

Step 4: Decorate your Properties

The final piece is to decorate your properties with the necessary attributes. The first and most obvious attribute needed is the DefaultValueAttribute. Any properties you want to define a default value for will need to be marked up.

At this point, if you build and try this out, you will encounter a wonderfully frustrating experience. Every time you add a new item, the default values will show in the editor, and when you close the editor dialog, the item and values will show in the list. But when you refresh the page, any column that has a DefaultValueAttribute defined, will be empty. Trust me, this is frustrating beyond belief. Don't do it.

The issue has to do with the way the values are serialized and saved, and thankfully it's easily handled with an extra attribute. You need to tell the Json Serializer how to handle the DefaultValue properties using a JsonPropertyAttribute.
[pre class="brush:csharp"]
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
    [DefaultValue("TN")]
    public string State { get; set; }
[/pre]

In the End...

...you wind up with a data class that looks something like this:
[pre class="brush:csharp"]
public class Address
{
    public string City { get; set; }
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
    [DefaultValue("TN")]
    public string State { get; set; }
    public string Zip { get; set; }
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
    [DefaultValue("USA")]
    [Editable(false)]
    public string Country { get; set; }
}
[/pre]
A property definition on your block or page that looks like this:
[pre class="brush:csharp"]
[EditorDescriptor(EditorDescriptorType = typeof(CustomCollectionEditor<Address>))]
[Display(GroupName = "Supported Regions")]
public virtual IList<Address> Regions { get; set; }
[/pre]
And a custom Editor Descriptor like:
[pre class="brush:csharp"]
public class CustomCollectionEditor<T> : CollectionEditorDescriptor<T> where T : new()
{
    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
    {
        base.ModifyMetadata(metadata, attributes);

        metadata.ClientEditingClass = "app/editors/CustomCollectionEditor";
        metadata.EditorConfiguration.Add("defaultItem", getDefaultValues((metadata.ModelType.GetGenericArguments()[0]).GetProperties()));
    }

    private Dictionary<string, object> getDefaultValues(IEnumerable<PropertyInfo> properties)
    {
        if (!properties.Any()) { return new Dictionary<string, object>(); }

        var defaultsList = properties.Where(t => (      // first get properties that have defaultvalues
                t.GetCustomAttributes<DefaultValueAttribute>(true).Any()
            )).ToDictionary(p => p.Name, p => p.GetCustomAttribute<DefaultValueAttribute>(true).Value);

        return defaultsList;
    }
}
[/pre]

Which results in an editor experience like this:


1 comment:

  1. how can you set the defultvalue of nested custom object like CountryInfo in the example with above logic

    public class Address
    {
    public Country CountryInfo { get; set; }
    }

    public class Country
    {
    public string City { get; set; }
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
    [DefaultValue("TN")]
    public string State { get; set; }
    public string Zip { get; set; }
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
    [DefaultValue("USA")]
    [Editable(false)]
    public string Country { get; set; }
    }

    ReplyDelete

Share your thoughts or ask questions...