Friday, June 21, 2019

Updated Image Support in PropertyList

While working on a past project with heavy use of IList<T> properties I ran into several usability situations. One of those situations involved read-only values that were being imported from an external system. They needed to appear in the CollectionEditor, but did not need to be editable (read about that here). Another scenario I encountered involved images in the CollectionEditor. It started off with a simple implementation of the solution provided by Grzegorz Wiecheć, but evolved into something more.

One of the challenges we faced was the customer's need for multiple images in the list, and those images not always sharing the same property name. As with the issue around default values, some of the data for this was being synchronized with an external system, so we needed to be flexible with the property names and the number of image properties that might be present in the list.

The Original

Grzegorz's solution is great in that it allows the CollectionEditor to display images inside the data table, instead of the content ID for the image. It makes the experience for the user cleaner and easier to understand. Again, if you haven't read up on his approach, take some time and go here first to fully understand where we're coming from: https://gregwiechec.com/2015/12/propertylist-with-images/.

The problem with this approach is that it requires the property name to be "Image", and coded into the CollectionEditor script to handle it properly. If you only have one image property in a list and you name it "image" every time, then go ahead and stop; the other solution works fine in that situation. However, if you have more than one image in a PropertyList, or you have multiple lists that will use the CollectionEditor where their property names for the images might differ, or you just like to overcomplicate things, keep reading.

Expanding the Original

The goal with my approach is to remove the need to hard code the image property name anywhere, and, instead, expand the original idea to look for any image properties on the list items, which would be denoted with the UIHint attribute value of Image.
[pre class="brush:csharp" title="Customer with image named Avatar"]
public class Customer
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
        [UIHint(UIHint.Image)]
        public ContentReference AlternateImage { get; set; }
        public string HomeAddress { get; set; }
        [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
        [DefaultValue("TN")]
        public string State { get; set; }
        [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
        [DefaultValue("37982")]
        public string ZipCode { get; set; }
        [UIHint(UIHint.Image)]
        public ContentReference Avatar { get; set; }
    }
[/pre]
To start, we need a way to associate the ContentReference ID of an Image property to the actual URL for the image so it can show up. There are 2 scenarios where we need to resolve the ContentReference ID to a URL:
  1. When the editor first loads and images already exist in the list 
  2. When a new item is added or an existing item is modified

Server Side

For the existing images a server side component is needed that will identify all the images for list items already created on the property, map their IDs to their URLs, and then provide that to the CollectionEditor on the front end. To do that, a new Editor Descriptor will be created, inheriting from the existing CollectionEditorDescriptor. This makes it easy to specify the EditorDescriptorType for an IList<T> property whenever this functionality is desired, and it also allows the original editor behavior to be used if desired.
[pre class="brush:csharp"]
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;

using EPiServer;
using EPiServer.Cms.Shell;
using EPiServer.Cms.Shell.UI.ObjectEditing.EditorDescriptors;
using EPiServer.Core;
using EPiServer.ServiceLocation;
using EPiServer.Shell.ObjectEditing;
using EPiServer.Web;
using EPiServer.Web.Routing;

using Sandbox.Models.Media;

public class CustomCollectionEditor<T> : CollectionEditorDescriptor<T> where T : new()
{
    // handle everything in here
}
[/pre]
Inside the CustomCollectionEditor the ModifyMetadata function is overridden for 3 main purposes:
  1. Set the script for the Dojo component of the editor
  2. Gather all the properties from the items in the list that are images
  3. Map all existing images in the list with an ID:URL reference
An additional method called getCollectionImages is added to do the heavy lifting of finding all the properties that are images, and mapping existing image IDs to their URL, which is needed to display the images that already exist in the list. If we don't add the mapping list from the start any existing images will show as an ID in the table, while new ones will show the image thumbnail.
[pre class="brush:csharp;class-name:'collapse-box'"]
public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
{
    base.ModifyMetadata(metadata, attributes);
    // this sets the Dojo script for the editor
    metadata.ClientEditingClass = "app/editors/CustomCollectionEditor";
    // these are all the properties in the list
    var listItemProperties = (metadata.ModelType.GetGenericArguments()[0]).GetProperties();

    // get all properties of our IEnumerable that contain UIHint for Image indicating we should render that reference as an Image
    var imageProps = Enumerable.Empty<PropertyInfo>();
    var imagesList = getCollectionImages(metadata.Model, listItemProperties, out imageProps);
    // add the values to the metadata for the editor. this is how we get it in the Dojo script
    metadata.EditorConfiguration.Add("imageProperties", imageProps.Select(p => toCamelCase(p.Name)));
    metadata.EditorConfiguration.Add("mappedImages", imagesList.Select(i => new
    {
        id = i.ContentLink.ID,
        imageName = i.Name,
        imageUrl = UrlResolver.Current.GetUrl(i.ContentLink, i.LanguageBranch(), new VirtualPathArguments() { ContextMode = ContextMode.Preview })
    }));
}

private List<ImageFile> getCollectionImages(object model, IEnumerable<PropertyInfo> properties, out IEnumerable<PropertyInfo> imageProperties)
{
    var contentRepo = ServiceLocator.Current.GetInstance<IContentRepository>();

    // this gets all properties that are marked with UIHint attribute value of Image
    imageProperties = properties.Where(t => (
        t.PropertyType == typeof(ContentReference) && t.GetCustomAttributes<UIHintAttribute>(true).Any()
    )).Where(p => p.GetCustomAttributes<UIHintAttribute>().Select(a => a.UIHint).Contains(UIHint.Image));

    List<ImageFile> imagesList = new List<ImageFile>();
    // make sure we're working with a property
    var modelData = model as PropertyData;
    if (modelData != null && modelData.Value != null)
    {
        var collection = modelData.Value as IEnumerable<T>; // this is the model for the property
        // loop through each item in the list and use the imageProperties to get the value of each image
        foreach (var item in collection)
        {
            foreach (var prop in imageProperties)
            {
                var imageRef = prop.GetValue(item) as ContentReference;
                if (imageRef != null && !imagesList.Any(i => i.ContentTypeID == imageRef.ID))
                {
                    var image = contentRepo.Get<ImageFile>(imageRef);
                    if (image != null) { imagesList.Add(image); }
                }
            }
        }
    }
    return imagesList;
}

private string toCamelCase(string name)
{
    if (string.IsNullOrWhiteSpace(name) || name.Length <= 1) { return name; }
    return Char.ToLowerInvariant(name[0]) + name.Substring(1);
}
[/pre]
What's happening here looks to be quite a bit, but it's actually pretty straight forward. The first thing worth noting is setting the ClientEditingClass property. The path points to the Dojo script for the editor, and the prefix "app" is actually an alias for the ClientResources/Scripts folder, as defined in the module.config file.

After setting the ClientEditingClass, all properties for the Type specified for the IList<T> property are retrieved so all the image properties can be identified. The ModelType property on the ExtendedMetadata passed to the editor descriptor is a reference to the model used for the property. With that reference, calling GetGenericArguments()[0] will give the Type T from IList<T> and then GetProperties() can be used to get the list of properties.
[pre class="brush:csharp"]
var listItemProperties = (metadata.ModelType.GetGenericArguments()[0]).GetProperties();
[/pre]
The listItemProperties are then passed off to the getCollectionImages method to first filter down to just the image properties, and then get the mappings. Any property that is a type ContentReference with a UIHint attribute value of Image is an image property to display.
[pre class="brush:csharp"]
imageProperties = properties.Where(t =>
    t.PropertyType == typeof(ContentReference) && t.GetCustomAttributes<UIHintAttribute>(true).Any())
    .Where(p => p.GetCustomAttributes<UIHintAttribute>().Select(a => a.UIHint).Contains(UIHint.Image));
[/pre]
Once the list of image properties is filtered out we can check for an existing value on the model data, and loop through the list in that value to retrieve all existing images based on that property list, which will serve to create the image mapping list after.
[pre class="brush:csharp;"]
List<ImageFile> imagesList = new List<ImageFile>();
// make sure we're working with a property
var modelData = model as PropertyData;
if (modelData != null && modelData.Value != null)
{
    var collection = modelData.Value as IEnumerable<T>; // this is the model for the property
    // loop through each item in the list and use the imageProperties to get the value of each image
    foreach (var item in collection)
    {
        foreach (var prop in imageProperties) 
        {
            var imageRef = prop.GetValue(item) as ContentReference;
            if (imageRef != null && !imagesList.Any(i => i.ContentTypeID == imageRef.ID))
            {
                var image = contentRepo.Get<ImageFile>(imageRef);
                if (image != null) { imagesList.Add(image); }
            }
        }
    }
}
return imagesList;
[/pre]
With the image properties identified and the existing images in the list value retrieved, the final piece on the server is to add those objects to the EditorDescriptor metadata so they can be used in the Dojo script. The image properties list is added as a list of property names converted to camel case to better match their Javascript counterparts. The list of images is converted to a mapping list with the ID, name, and URL to use for reference in the Dojo script.
[pre class="brush:csharp;"]
// add the values to the metadata for the editor. this is how we get it in the Dojo script
metadata.EditorConfiguration.Add("imageProperties", imageProps.Select(p => toCamelCase(p.Name)));
metadata.EditorConfiguration.Add("mappedImages", imagesList.Select(i => new
{
    id = i.ContentLink.ID,
    imageName = i.Name,
    imageUrl = UrlResolver.Current.GetUrl(i.ContentLink, i.LanguageBranch(), new VirtualPathArguments() { ContextMode = ContextMode.Preview })
}));
[/pre]

Client Side

On the client side there is more to do since a custom formatter is needed for handling images in the CollectionEditor, and a new widget for the CustomCollectionEditor is needed to handle the new behavior for applying the formatter.

The CustomCollectionEditor inherits from the CollectionEditor widget already available, which simplifies the process by allowing just the addition or override of functionality where needed.
[pre class="brush:js;class-name:'collapse-box'"]
define([
    "dojo/_base/array",
    "dojo/_base/declare",
    "dojo/_base/lang",
    "dojo/when",
    "dojo/promise/all",
    "epi/string",
    "epi-cms/contentediting/editors/CollectionEditor",

    // dijit
    "dijit/_TemplatedMixin",

    // customized components
    "./extensions/ExtendedFormatters",
],
    function (
        array,
        declare,
        lang,
        when,
        all,
        epiString,
        CollectionEditor,

        // dijit
        _TemplatedMixin,

        ExtendedFormatters,
    ) {
        return declare([CollectionEditor],
        {
            _setImageMappings: function () {
                // set client mappedImages from backend mappings
                var mappedImages = this.mappedImages || [];
                // loop through mapped images and get the correct output to show them
                for (var i = 0; i < mappedImages.length; i++) {
                    var mappedImage = mappedImages[i];
                    if (mappedImage && mappedImage.id) {
                        ExtendedFormatters.setImageMapping(mappedImage.id, mappedImage);
                    }
                }
            },
            _setFormatters: function (includedColumns, excludedColumns, imageProperties, readOnly) {
                array.forEach(array.filter(this.model.itemMetadata.properties, function (property) {
                    // filter exlcuded properties
                    return array.every(excludedColumns, function (col) {
                        return col !== property.name;
                    });
                }), function (property) {
                    // set formatter on image properties and add class to column
                    var columnName = epiString.pascalToCamel(property.name);
                    if (imageProperties.includes(columnName)) {
                        includedColumns[columnName].formatter = ExtendedFormatters.imageFormatter;
                        var currentClass = includedColumns[columnName].className;
                        includedColumns[columnName].className = `${currentClass || ""} dgrid-image-column`;
                    }
                });
                return includedColumns;
            },
            _getGridDefinition: function () {
                // get the arguments from the base method. this includes the column definitions
                var result = this.inherited(arguments);
                this._setImageMappings();   // set mappings for existing images
                // set the formatters for the image columns
                result = this._setFormatters(result, this.excludedColumns, this.imageProperties, this.readOnly);
                return result;
            },
            onExecuteDialog: function () {
                var item = this._itemEditor.get("value");
                // loop through all possible image properties and resolve the images - the properties come from Editor Descriptor
                var imageResolvers = array.map(this.imageProperties, function (prop) {
                    return ExtendedFormatters.resolveImageMapping(item[prop]);
                }, this);
                // when all images are resolved, save or add the item to the model
                when(all(imageResolvers), lang.hitch(this, function () {
                    if (this._editingItemIndex !== undefined) {
                        this.model.saveItem(item, this._editingItemIndex);
                    } else {
                        this.model.addItem(item);
                    }
                }));
            }
        });
    }
);
[/pre]
Because of the added complexity of handling multiple image properties, this widget code differs a bit from the original approach, and to make it easier to read multiple functions have been added to break it down a bit more.

Stepping through the code to make sense of it, the first method is _setImageMappings. This method is where any existing images are handled by the formatter to be displayed in the grid.
[pre class="brush:js"]
_setImageMappings: function () {
    // set client mappedImages from backend mappings
    var mappedImages = this.mappedImages || [];
    // loop through mapped images and get the correct output to show them
    for (var i = 0; i < mappedImages.length; i++) {
        var mappedImage = mappedImages[i];
        if (mappedImage && mappedImage.id) {
            ExtendedFormatters.setImageMapping(mappedImage.id, mappedImage);
        }
    }
}
[/pre]
The next function down the line is the _setFormatters function. This function is responsible for looping through all of the columns that were defined for the data grid to be displayed, checking to see if the name of the column matches one of the identified image property names, and attaching the new image formatter if so. Additionally, to make it possible to style the image columns a bit differently, a CSS class is added to the image columns.
[pre class="brush:js"]
_setFormatters: function (includedColumns, excludedColumns, imageProperties, readOnly) {
    array.forEach(array.filter(this.model.itemMetadata.properties, function (property) {
        // filter exlcuded properties
        return array.every(excludedColumns, function (col) {
            return col !== property.name;
        });
    }), function (property) {
        // set formatter on image properties and add class to column
        var columnName = epiString.pascalToCamel(property.name);
        if (imageProperties.includes(columnName)) {
            includedColumns[columnName].formatter = ExtendedFormatters.imageFormatter;
            var currentClass = includedColumns[columnName].className;
            includedColumns[columnName].className = `${currentClass || ""} dgrid-image-column`;
        }
    });
    return includedColumns;
}
[/pre]
The _getGridDefinition function is actually an override of the inherited function from the original CollectionEditor code, and is responsible for calling the two functions above, right after the grid definitions are created for the collection editor. The inherited(arguments) line is like executing the base method and retrieving the value from it. In this case, the "result" parameter consists of the columns defined in the base method.
[pre class="brush:js"]
_getGridDefinition: function () {
    // get the arguments from the base method. this includes the column definitions
    var result = this.inherited(arguments);
    this._setImageMappings();   // set mappings for existing images
    // set the formatters for the image columns
    result = this._setFormatters(result, this.excludedColumns, this.imageProperties, this.readOnly);
    return result;
}
[/pre]
Finally, the onExecuteDialog function is an override of the base method, and is triggered when an editor adds or edits an item in the CollectionEditor. This method is executed when the edit dialog closes, and any image properties that have been given a value will be passed to the custom formatter in order to output the markup for displaying the image before the data is saved back to the model.
[pre class="brush:js"]
onExecuteDialog: function () {
    var item = this._itemEditor.get("value");
    // loop through all possible image properties and resolve the images - the properties come from Editor Descriptor
    var imageResolvers = array.map(this.imageProperties, function (prop) {
        return ExtendedFormatters.resolveImageMapping(item[prop]);
    }, this);
    // when all images are resolved, save or add the item to the model
    when(all(imageResolvers), lang.hitch(this, function () {
        if (this._editingItemIndex !== undefined) {
            this.model.saveItem(item, this._editingItemIndex);
        } else {
            this.model.addItem(item);
        }
    }));
}
[/pre] An important thing to note is that, while the grid displays the image using the URL and markup provided by the formatter, the value saved to the model is still the ContentReference ID.

The Formatter

The last client side piece of this is the custom formatter that handles the resolving of the ContentReference ID for the image property to the URL. This formatter is essentially the same as the one from the original approach, with only a few modifications.
[pre class="brush:js;class-name:'collapse-box'"]
define([
    // dojo
    "dojo/_base/declare",
    "dojo/_base/lang",
    "dojo/Deferred",
    "dojo/parser",
    "dojo/ready",
    "epi/dependency",

    // dijit

    "dijit/_TemplatedMixin",
    "dijit/_Widget",

    // template
    "dojo/text!../templates/ImageFormat.html",  // new template
],
    function (
        // dojo
        declare,
        lang,
        Deferred,
        parser,
        ready,
        dependency,
        //dijit
        _Templated,
        _Widget,
        // template
        imageFormatTemplate
    ) {
        var ImageFormatter = declare([_Widget, _Templated], {
            templateString: imageFormatTemplate,
            // image object for template
            image: {}
        });

        function resolveContentData(contentlink, callback) {
            if (!contentlink) {
                return null;
            }

            var registry = dependency.resolve("epi.storeregistry");
            var store = registry.get("epi.cms.content.light");

            var contentData;
            dojo.when(store.get(contentlink),
                function (returnValue) {
                    contentData = returnValue;
                    callback(contentData);
                });
            return contentData;
        }

        var images = {};

        var extendedFormatters = {
            imageFormatter: function (value) {
                if (!value) return '-';
                if (!images[value]) return value;

                // creating a widget for ImageFormatter so we can support templating for image markup
                var imageFormatter = new ImageFormatter({
                    image: images[value]
                });
                return imageFormatter.domNode.innerHTML;
            },

            resolveImageMapping: function (contentLink) {
                var def = new Deferred();
                if (!contentLink) { return def.resolve(); }
                if (images[contentLink]) { return def.resolve(); }
                resolveContentData(contentLink,
                    function (contentData) {
                        images[contentLink] = images[contentLink] || {};
                        images[contentLink].id = contentLink;
                        images[contentLink].imageName = contentData.name;
                        images[contentLink].imageUrl = contentData.previewUrl;
                        def.resolve();
                    });
                return def.promise;
            },

            setImageMapping: function (contentLink, metadata) {
                images[contentLink] = metadata;
            }
        };
        return extendedFormatters;
    }
);
[/pre]
In this version of the formatter, the resolveImageMapping function has been modified to add additional data to the image object, which allows that additional data to be displayed in the grid to supply the editor with a bit more information if desired. This is used in the ImageFormat template discussed further below.

The bigger change is in the imageFormatter function, swapping out the HTML markup in the formatter code to use an ImageFormatter widget instead. Using Dojo's TemplatedMixin, a template is specified as a declaration referencing an ImageFormat.html file that contains the HTML markup along with substitution variables that render the values from the image object created in the resolveImageMapping function. This allows greater control over the markup for the image preview in the CollectionEditor, and makes it easier to update it without disturbing the widget code. I have another blog post that talks about this approach to Replacing HTML strings with Dojo TemplatedMixin.
[pre class="brush:xhtml" title="The ImageFormat template"]
<div>
    <div class="ce-imageBlock">
        <div class=" ce-image">
            <img alt="" src="${image.imageUrl}" />
        </div>
        <div class="ce-imageInfo">
            <span class="dijitInline epi-card__title dojoxEllipsis" title="${image.imageName}">${image.imageName}</span>
        </div>
    </div>
</div>
[/pre]

Putting It To Use

To tie this all together and make it work, a few more steps are required. The first is the configuration inside the module.config file at the root of the project. As mentioned with the EditorDescriptor server side code, the "app" part of the ClientEditingClass is an alias to the ClientResources/Scripts directory, and is defined in the module.config file. You can also specify special CSS files to load during edit mode in Episerver using the module.config file, and since I am adding a class to the image column through the widget, and have added classes in the ImageFormat template, it only makes sense to define some CSS rules that take advantage of them. 
[pre class="brush:xml" title="module.config"]
<?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>
  <clientResources>
    <add name="epi-cms.widgets.base" path="Scripts/Editors/templates/collectionEditor.css" resourceType="Style"/>
  </clientResources>
</module>
[/pre]
[pre class="brush:css" title="collectionEditor.css"]
.dgrid-image-column {
    width: 150px;
}
.ce-imageBlock {
    width: 100%;
}
    .ce-imageBlock img {
        width: 100%;
    }
    .ce-imageInfo {
        width: 100%;
        padding: 2px 4px;
        border-radius: 2px;
        background: #fff;
        box-sizing: border-box;
    }
[/pre]
All of these files should reside in your ClientResources folder, at the root of your site, and should match the paths you use in your config file and for your declarations in the Dojo scripts. My directory is organized with folders and my Dojo references use relative paths, so my folder structure looks like this:

If everything is in the correct location, you can begin using this new collection editor with images. Because it's defined as a new EditorDescriptor, you can simply specify the EditorDescriptorType on the property.
[pre class="brush:csharp"]
[EditorDescriptor(EditorDescriptorType = typeof(CustomCollectionEditor<Customer>))]
[Display(Name = "Some Other Customer Field",
    Description = "List of current customers",
    Order = 9)]
public virtual IList<Customer> CustomersField { get; set; }
[/pre]
With that in place you should be presented with a CollectionEditor grid table on the page with image previews in it. With my editor CSS and ImageFormat template you will also see the image name.


No comments:

Post a Comment