Showing the friendly URL of a Content Reference or Url property in a PropertyList

Edit (20.11.2020): I’ve created a NuGet package which makes a much-improved version of this functionality easily installable. You can read the blog post, see it in the NuGet feed or get more details from the GitHub repository readme.

I was really trying to avoid blogging about any more custom properties, things related to PropertyList or Episerver editor UI customization. However, I felt like digging into how easy (or hard) it would be to show friendly URLs for ContentReference and Url properties in a PropertyList, so I thought I may as well revisit all of the areas I was trying to avoid in one blog post!

This solution is heavily based on a great 2015 blog post by Grzegorz Wiecheć called PropertyList with images, if you compare the code you'll see that his approach very much informed the approach I took here (and I reused code as much as I could) — I also think this reinforces the value of blogging about solutions and sharing code. I should also preface this post by saying that the code here is essentially a code experiment and I'm sharing it with that proviso. Finally, it's worth noting that Episerver don't actually support using the Url property in a PropertyList (see this forum answer) so it's probably worth keeping that in mind.

That being said, what we're trying to do is go from something like this:

to something like this:

Here Url is a Url property, and Link is a ContentReference, so our list item looks something like this:

public class Item
{
    [Display(Name = "Name", Order = 10)]
    public string Name { get; set; }

    [JsonConverter(typeof(UrlConverter))]
    [Display(Name = "Url", Order = 20)]
    public Url Url { get; set; }

    [Display(Name = "Link", Order = 30)]
    public ContentReference Link { get; set; }
}

To start with we run into the same issue as Grzegorz, in that we need to resolve friendly URLs before rendering (as he explains, this is because the grid formatter doesn't work with Deferreds). This is a bit of hassle because it means we need to resolve URLs from the backend for the initial load, and also from the frontend — when we close the dialog.

Let's start with the backend:

The IMetadataAware attribute

As mentioned, we need a way to get our friendly URLs to the front end on initialization, this is what the IMetadataAware attribute is responsible for in this instance:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using EPiServer.Core;
using EPiServer.Shell.ObjectEditing;
using EPiServer.Web;
using EPiServer.Web.Routing;
using Newtonsoft.Json;

namespace EPiServer.Reference.Commerce.Site
{
    public class FriendlyPropertyListUrlAttribute : Attribute, IMetadataAware
    {
        public ICollection<string> FieldNames { get; }

        public FriendlyPropertyListUrlAttribute(params string[] fieldNames)
        {
            FieldNames = fieldNames;
        }

        public void OnMetadataCreated(ModelMetadata metadata)
        {
            var extendedMetadata = (ExtendedMetadata)metadata;

            // Add our field names to the metadata.
            extendedMetadata.EditorConfiguration.Add("fieldNames", GetCamelCaseFieldNames(FieldNames));

            var model = metadata.Model as dynamic;

            if (model?.List == null)
            {
                return;
            }

            // A dictionary which will contain our mappings for both our ContentReferences
            // and URLs to friendly URLs.
            IDictionary<string, string> urls = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

            foreach (dynamic item in model.List)
            {
                var properties = item.GetType().GetProperties();

                foreach (var property in properties)
                {
                    var url = GetValue<Url>(property, item);
    
                    if (url != null && !url.IsEmpty())
                    {
                        if (!urls.ContainsKey(url.ToString()))
                        {
                            urls.Add(url.ToString(), UrlResolver.Current.GetUrl(new UrlBuilder(url), ContextMode.Default));
                        }
                    }

                    var contentLink = GetValue<ContentReference>(property, item);

                    if (!ContentReference.IsNullOrEmpty(contentLink))
                    {
                        if (!urls.ContainsKey(contentLink.ID.ToString()))
                        {
                            urls.Add(contentLink.ID.ToString(), UrlResolver.Current.GetUrl(contentLink));
                        }
                    }
                }
            }

            // Add our URL mappings to the metadata.
            extendedMetadata.EditorConfiguration.Add("urlMappings", urls);
        }

        /// <summary>
        /// Gets the value of a property as the correct type.
        /// </summary>
        /// <typeparam name="T">Type of the property.</typeparam>
        /// <param name="property">The property.</param>
        /// <param name="item">The parent object.</param>
        /// <returns>The property value.</returns>
        private static T GetValue<T>(dynamic property, dynamic item)
        {
            if (typeof(T).IsAssignableFrom(property.PropertyType))
            {
                return (T)property.GetValue(item, null);
            }

            return default(T);
        }

        /// <summary>
        /// Converts Pascal Case field names into Camel Case via Newtonsoft.Json.
        /// </summary>
        /// <param name="fieldNames">The field names.</param>
        /// <returns>The field names in Camel Case.</returns>
        private static ICollection<string> GetCamelCaseFieldNames(IEnumerable<string> fieldNames)
        {
            IDictionary<string, int> dict = new Dictionary<string, int>();

            foreach (var fieldName in fieldNames)
            {
                dict.Add(fieldName, 0);
            }

            var serializedObject = JsonConvert.SerializeObject(dict, new JsonSerializerSettings
            {
                ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
            });

            var result = JsonConvert.DeserializeObject<Dictionary<string, int>>(serializedObject);

            return result.Keys.ToList();
        }
    }
}

As you can see I tried to keep things as generic as possible, so it resolves the values of all Url and ContentReference properties, gets the friendly URLs and builds a dictionary of the mappings (which is then passed to the frontend).

To avoid hard-coding the field names in the frontend the constructor also takes a string array which represents names of properties you'd like to show friendly URLs for. These are processed by the GetCamelCaseFieldNames method, which uses Newtonsoft.Json to convert the specified property names into camel case.

Usage of the attribute therefore ends up looking something like this:

[Display(Name = "Items", Order = 10)]
[EditorDescriptor(EditorDescriptorType = typeof(CollectionEditorDescriptor<Item>))]
[FriendlyPropertyListUrl(nameof(Item.Link), nameof(Item.Url))]
public virtual IList<Item> Items { get; set; }

Now we have everything we need we can continue onto the Dojo stuff...

The custom CollectionEditor

This is very similar to Grzegorz's ExtendedCollectionEditor.js:

define([
        "dojo/_base/array",
        "dojo/_base/declare",
        "dojo/_base/lang",
        "dojo/DeferredList",
        "epi-cms/contentediting/editors/CollectionEditor",
        "example/extendedFormatters"
    ],
    function (
        array,
        declare,
        lang,
        DeferredList,
        CollectionEditor,
        extendedFormatters
    ) {
        return declare([CollectionEditor], {
            _getGridDefinition: function () {
                var result = this.inherited(arguments);

                extendedFormatters.setUrlMappings(this.urlMappings);

                for (var i = 0; i < this.fieldNames.length; i++) {
                    result[this.fieldNames[i]].formatter = extendedFormatters.urlFormatter;
                }

                return result;
            },
            onExecuteDialog: function () {
                var item = this._itemEditor.get("value");

                var contentUrls = [];

                for (var i = 0; i < this.fieldNames.length; i++) {
                    var value = item[this.fieldNames[i]];

                    if (isNaN(value)) {
                        contentUrls.push(extendedFormatters.getContentUrlByPermanentLink(value));
                    } else {
                        contentUrls.push(extendedFormatters.getContentUrlByContentLink(value));
                    }
                }

                var dl = new DeferredList(contentUrls);

                dl.then(lang.hitch(this, function () {
                    if (this._editingItemIndex !== undefined) {
                        this.model.saveItem(item, this._editingItemIndex);
                    } else {
                        this.model.addItem(item);
                    }
                }));
            }
        });
    });

I really just have a couple of changes to mention:

  1. _getGridDefinition now iterates over the fieldNames originating from the FriendlyPropertyListUrlAttribute setting the formatter for each specified field.
  2. We do something very similar for the onExecuteDialog method where it was now necessary to create a DeferredList and wait on all responses. You can see that I identify whether the value is a ContentReference or Url with isNaN.

That's really it!

The formatter

Again, I based this heavily on the extendedFormatters.js in the aforementioned blog post. I think for the most part it speaks for its self:

define([
    // dojo
    "dojo/_base/lang",
    "dojo/Deferred",
    // epi
    "epi/dependency",
    "epi-cms/core/PermanentLinkHelper"
],
    function (
        // dojo
        lang,
        Deferred,
        // epi
        dependency,
        PermanentLinkHelper
    ) {
        function getContentByContentLink(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 urls = {};

        var extendedFormatters = {
            urlFormatter: function (value) {
                if (!value) {
                    return '';
                }

                if (!urls[value]) {
                    return value;
                }

                return urls[value];
            },

            getContentUrlByPermanentLink: function (link) {
                var def = new Deferred();

                if (urls[link]) {
                    def.resolve();
                    return def.promise;
                }

                dojo.when(PermanentLinkHelper.getContent(link), function (contentData) {
                    if (contentData) {
                        urls[link] = contentData.publicUrl;
                    } else {
                        // Probably an external link.
                        urls[link] = link;
                    }
                    def.resolve();
                });

                return def.promise;
            },

            getContentUrlByContentLink: function (contentLink) {
                var def = new Deferred();

                if (urls[contentLink]) {
                    def.resolve();
                    return def.promise;
                }

                getContentByContentLink(contentLink, function (contentData) {
                    if (contentData) {
                        urls[contentLink] = contentData.publicUrl;
                    }
                    def.resolve();
                });

                return def.promise;
            },

            setUrlMappings: function (urlMappings) {
                urls = urlMappings || {};
            }
        };

        return extendedFormatters;
    });

Aside from renaming some methods and some small tweaks the only major add is getContentUrlByPermanentLink which essentially just uses the Episerver PermanentLinkHelper. This method just gets the content based on a permanent link, we then maintain the mappings in our urlMappings.

Wrapping up

To tie this all together we just need to tell Episerver to use our custom CollectionEditor, you can do this with a ClientEditorAttribute, meaning your property will now look like this:

[Display(Name = "Items", Order = 10)]
[EditorDescriptor(EditorDescriptorType = typeof(CollectionEditorDescriptor<Item>))]
[FriendlyPropertyListUrl(nameof(Item.Link), nameof(Item.Url))]
[ClientEditor(ClientEditingClass = "example/ExtendedCollectionEditor")]
public virtual IList<Item> Items { get; set; }

Finally, if you're copying and pasting the JavaScript from above (and the ClientEditorAttribute usage) you'll need to be aware that all my JavaScript files were located in /ClientResources/Scripts/ and 'example' is configured to use this path in the module.config, which looked like this:

<?xml version="1.0" encoding="utf-8"?>
<module>
  <dojo>
    <paths>
      <add name="example" path="Scripts" />
    </paths>
  </dojo>
</module>

Well, we're done — for better or worse we can now show friendly URLs to editors in a PropertyList. Just to re-iterate, this code is something I put together for this blog post and comes with no quality guarantees. Aside from that: feel free to go crazy with it!

Comments

Great post Jake. However, it doesn't seem to work with the latest version of the CMS. Whenever you attempt to submit the dialog, it throws the following: Uncaught TypeError: Cannot read property '/link/0e0c099bfe8b49c1a5376ac67f9e2034.aspx' of undefined at Object.getContentUrlByPermanentLink (extendedFormatters.js:53) at Object.onExecuteDialog (ExtendedCollectionEditor.js:38) at Object._onDialogExecute (widgets.js:2) at Object.<anonymous> (dojo.js:formatted:1842) at Object._264 [as onExecute] (dojo.js:formatted:3397) at Object._onSubmit (widgets.js:formatted:2861) at Object._19e (dojo.js:formatted:2414) at Object._onSubmit (widgets.js:formatted:9308) at Object._onClick (widgets.js:formatted:5995) at Object._19e (dojo.js:formatted:2414)

David W.

Hi David, The error is because of the urlMappings has not been initialized, in the setUrlMappings method just replace urls = urlMappings; with urls = urlMappings || {};

Peng

Hey Jake. Everything works except when I what to remove the link from the list. I click on the x for removing link and OK for closing the dialog but link is still in the list. Do you have any idea why?

Andrej

@Peng: Thanks for that fix, I've updated the code to reflect it.

Jake

@Andrej: I couldn't recreate your issue. Do you see anything in the console? My feeling is that it shouldn't be related to the code above—but then I would say that!

Jake

@jake: good post.

Yogesh

Hi All, for those having problem with the list not showing. DeferredList should be called "var dl = new dojo.DeferredList(contentUrls);" - thx for the code Jake! see ya

LucGosso