Posted in: Comments

Screenshot from Episerver editing UISince I started working with Episerver back in 2008 I've been missing better taxonomy support, that and a "bucket" feature where we can put content that doesn't fit into a content tree, but in this post I will just cover taxonomy (buckets might be my next challenge though).

If you're like me, you've probably always wondered why you need admin access to just administer categories and why it's so cumbersome to translate them. Well, you don't have to anymore! Head over to GitHub and check my new project out at https://github.com/JohanPetersson/episerver-taxonomy.

You'll find more information at GitHub, but in short this Add-on gives editors a new UI to manage taxonomy and us, developers, a new way to define different types of taxonomy.

We can define taxonomy by inheriting from TaxonomyData:

using Dodavinkeln.Taxonomy.Core;
using EPiServer.DataAnnotations;

[ContentType(GUID = "974ebaf3-c6fc-4332-a809-344b7e372f21")]
public class CategoryData : TaxonomyData
{
}

And then add taxonomy properties to content types:

[Taxonomy]
[Display(Name = "News category")]
public virtual ContentReference NewsCategory { get; set; }

The Add-on is not release in a public feed yet, I would like to have some feedback and implement a few more features before releasing 1.0. So please check it out and leave comments here or on GitHub.

Posted in: Comments

Lets say you want to give your editors the possibility to select a user (username) in a property so we can display e.g. a byline in an article. The simple solution is to just add a string property to your page type, but then the editor need to enter the username manually. Instead we can use the AutoSuggestSelection attribute, then we only need to implement an ISelectionQuery.

Fortunately there is an API in Episerver we can use to query for users, IQueryableNotificationUsers, but keep in mind that the API is internal and may change without notice.

First we need to implement our query:

namespace DV
{
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using EPiServer.Notification;
    using EPiServer.ServiceLocation;
    using EPiServer.Shell.ObjectEditing;

    [ServiceConfiguration(typeof(ISelectionQuery))]
    public class UserSelectionQuery : ISelectionQuery
    {
        private readonly IQueryableNotificationUsers userRepository;

        public UserSelectionQuery(IQueryableNotificationUsers userRepository)
        {
            this.userRepository = userRepository;
        }

        public ISelectItem GetItemByValue(string value)
        {
            var task = Task.Run(async () => await this.userRepository.FindAsync(value, 0, 1));

            task.Wait();

            if (task.Result.TotalCount == 0)
            {
                return null;
            }

            var user = task.Result.PagedResult.Single();

            return new SelectItem
            {
                Text = $"{user.DisplayName} ({user.UserName})",
                Value = user.UserName
            };
        }

        public IEnumerable<ISelectItem> GetItems(string query)
        {
            var task = Task.Run(async () => await this.userRepository.FindAsync(query, 0, 10));

            task.Wait();

            if (task.Result.TotalCount == 0)
            {
                return Enumerable.Empty<ISelectItem>();
            }

            return task.Result.PagedResult.Select(user => new SelectItem
            {
                Text = $"{user.DisplayName} ({user.UserName})",
                Value = user.UserName
            });
        }
    }
}

Then we can use it in the attribute:

[AutoSuggestSelection(typeof(UserSelectionQuery))]
[Display(Name = "Published by")]
public virtual string PublishedBy { get; set; }

Hope this helps.

Posted in: Comments

I couldn't find constants for all Episerver action icons located here http://ux.episerver.com/ anywhere in Episerver or on the Internet. So I thought I would share those with you. These are really useful if you e.g. are using this solution.

public static class ContentIcons
{
    public const string Clock = "epi-iconClock";
    public const string Checkmark = "epi-iconCheckmark";
    public const string Stop = "epi-iconStop";
    public const string Versions = "epi-iconVersions";
    public const string Revert = "epi-iconRevert";
    public const string Undo = "epi-iconUndo";
    public const string Redo = "epi-iconRedo";
    public const string Pen = "epi-iconPen";
    public const string PenDisabled = "epi-iconPenDisabled";
    public const string DuplicatePage = "epi-iconDuplicatePage";
    public const string Primary = "epi-iconPrimary";
    public const string Trash = "epi-iconTrash";
    public const string Users = "epi-iconUsers";
    public const string Search = "epi-iconSearch";
    public const string Plus = "epi-iconPlus";
    public const string Minus = "epi-iconMinus";
    public const string Star = "epi-iconStar";
    public const string Settings = "epi-iconSettings";
    public const string Lock = "epi-iconLock";
    public const string Pin = "epi-iconPin";
    public const string Page = "epi-iconPage";
    public const string Folder = "epi-iconFolder";
    public const string SharedBlock = "epi-iconSharedBlock";
    public const string Up = "epi-iconUp";
    public const string Down = "epi-iconDown";
    public const string Reload = "epi-iconReload";
    public const string Share = "epi-iconShare";
    public const string Download = "epi-iconDownload";
    public const string DnD = "epi-iconDnD";
    public const string ContextMenu = "epi-iconContextMenu";
    public const string Rename = "epi-iconRename";
    public const string Left = "epi-iconLeft";
    public const string Right = "epi-iconRight";
    public const string User = "epi-iconUser";
    public const string Hidden = "epi-iconHidden";
    public const string NewWindow = "epi-iconNewWindow";
    public const string Catalog = "epi-iconCatalog";
    public const string Category = "epi-iconCategory";
    public const string Product = "epi-iconProduct";
    public const string SKU = "epi-iconSKU";
    public const string Package = "epi-iconPackage";
    public const string Bundle = "epi-iconBundle";
    public const string Boxes = "epi-iconBoxes";
    public const string References = "epi-iconReferences";
    public const string Bubble = "epi-iconBubble";
    public const string Location = "epi-iconLocation";
    public const string Mail = "epi-iconMail";
    public const string Telephone = "epi-iconTelephone";
    public const string Website = "epi-iconWebsite";
    public const string Link = "epi-iconLink";
    public const string Upload = "epi-iconUpload";
    public const string Error = "epi-iconError";
    public const string Warning = "epi-iconWarning";
    public const string Info = "epi-iconInfo";
    public const string Pricing = "epi-iconPricing";
    public const string Cut = "epi-iconCut";
    public const string Copy = "epi-iconCopy";
    public const string Paste = "epi-iconPaste";
    public const string Detach = "epi-iconDetach";
    public const string List = "epi-iconList";
    public const string Thumbnails = "epi-iconThumbnails";
    public const string Tiles = "epi-iconTiles";
    public const string Project = "epi-iconProject";
    public const string Published = "epi-iconPublished";
    public const string PreviouslyPublished = "epi-iconPreviouslyPublished";
    public const string Truck = "epi-iconTruck";
    public const string Cart = "epi-iconCart";
    public const string Sort = "epi-iconSort";
    public const string Campaign = "epi-iconCampaign";
    public const string PublishProject = "epi-iconPublishProject";
    public const string Promotion = "epi-iconPromotion";
    public const string Layout = "epi-iconLayout";
    public const string SortAscending = "epi-iconSortAscending";
    public const string SortDescending = "epi-iconSortDescending";
    public const string Edited = "epi-iconEdited";
    public const string Bell = "epi-iconBell";
}

  

Posted in: Comments

This topic comes up from time to time in the forums. I don’t find some of the solutions given completely adequate. They usually just makes the URL absolute by appending the current context’s host name, if present. What we should do instead, is to load the SiteDefinition for the content passed in to our method and then load the host from that definition. The site definition can contain multiple hosts and different kinds as well, e.g. primary host.

This method also works for MediaData.

public static string ContentExternalUrl(this ContentReference contentLink, CultureInfo contentLanguage, bool absoluteUrl)
{
    var result = ServiceLocator.Current.GetInstance<UrlResolver>().GetUrl(
        contentLink,
        contentLanguage.Name,
        new VirtualPathArguments
        {
            ContextMode = ContextMode.Default,
            ForceCanonical = absoluteUrl
        });

    // HACK: Temprorary fix until GetUrl and ForceCanonical works as expected,
    // i.e returning an absolute URL even if there is a HTTP context that matches the content's site definition and host.
    if (absoluteUrl)
    {
        Uri relativeUri;

        if (Uri.TryCreate(result, UriKind.RelativeOrAbsolute, out relativeUri))
        {
            if (!relativeUri.IsAbsoluteUri)
            {
                var siteDefinitionResolver = ServiceLocator.Current.GetInstance<SiteDefinitionResolver>();
                var siteDefinition = siteDefinitionResolver.GetDefinitionForContent(contentLink, true, true);
                var hosts = siteDefinition.GetHosts(contentLanguage, true).ToList();

                var host = hosts.FirstOrDefault(h => h.Type == HostDefinitionType.Primary) 
                    ?? hosts.FirstOrDefault(h => h.Type == HostDefinitionType.Undefined);

                var basetUri = siteDefinition.SiteUrl;

                if (host != null && host.Name.Equals("*") == false)
                {
                    // Try to create a new base URI from the host with the site's URI scheme. Name should be a valid
                    // authority, i.e. have a port number if it differs from the URI scheme's default port number.
                    Uri.TryCreate(siteDefinition.SiteUrl.Scheme + "://" + host.Name, UriKind.Absolute, out basetUri);
                }

                var absoluteUri = new Uri(basetUri, relativeUri);

                return absoluteUri.AbsoluteUri;
            }
        }
    }

    return result;
}

Posted in: Comments

Since my last post I’ve discovered a few issues/bugs with my approach while making rendering of properties more consistent. Unfortunately my method didn’t work that well in preview mode and Content Area properties got wrapped twice!

I couldn’t find an easy way since there is no method to override when properties are rendered in preview mode (these are rendered inside GetHtmlForEditMode()) and no way of telling if the property is a Content Area inside GetHtmlForDefaultMode(). So instead I had to override the PropertyFor() method which made the code much overly complicated and I also had to reflect some private methods from the default PropertyRenderer.

Here is the new complete code:

/// <summary>
///     Overrides the default renderer of all properties, so if we specify a CustomTag(Name) and optionally CssClass they get
///     rendered in view and preview mode as well. The default renderer only wraps the property in edit mode, except for
///     content areas which are always wrapped.
/// </summary>
public class SitePropertyRenderer : PropertyRenderer
{
    private readonly CachingViewEnginesWrapper viewResolver;

    public SitePropertyRenderer(CachingViewEnginesWrapper viewResolver)
    {
        this.viewResolver = viewResolver;
    }

    public override MvcHtmlString PropertyFor<TModel, TValue>(HtmlHelper<TModel> html, string viewModelPropertyName, object additionalViewData, object editorSettings, Expression<Func<TModel, TValue>> expression, Func<string, MvcHtmlString> displayForAction)
    {
        var contextMode = html.ViewContext.RequestContext.GetContextMode();

        // Properties are always wrapped in edit mode, so no need for custom rendering
        if (contextMode == ContextMode.Edit)
        {
            return base.PropertyFor(html, viewModelPropertyName, additionalViewData, editorSettings, expression, displayForAction);
        }

        var routeValueDictionaries = new RouteValueDictionary(additionalViewData);
        var templateName = this.ResolveTemplateName(html, routeValueDictionaries, expression);
        var isContentArea = this.PropertyIsContentArea(html, expression);

        // Content areas are always wrapped, so no need for custom rendering in view mode.
        if (isContentArea)
        {
            return displayForAction(templateName);
        }

        string elementName = null;

        if (routeValueDictionaries.ContainsKey("CustomTag"))
        {
            elementName = routeValueDictionaries["CustomTag"] as string;
        }

        // Correctly spelled property as well, since Episerver probably made a mistake here
        if (routeValueDictionaries.ContainsKey("CustomTagName"))
        {
            elementName = routeValueDictionaries["CustomTagName"] as string;
        }

        string cssClass = null;

        if (routeValueDictionaries.ContainsKey("CssClass"))
        {
            cssClass = routeValueDictionaries["CssClass"] as string;
        }

        return this.GetHtmlForDefaultAndPreviewMode(templateName, elementName, cssClass, displayForAction);
    }

    private MvcHtmlString GetHtmlForDefaultAndPreviewMode(string templateName, string elementName, string cssClass, Func<string, MvcHtmlString> displayForAction)
    {
        // Rely on standard behavior if no element is specified
        if (string.IsNullOrEmpty(elementName))
        {
            return displayForAction(templateName);
        }

        var html = displayForAction(templateName).ToHtmlString();

        if (string.IsNullOrEmpty(html))
        {
            return MvcHtmlString.Empty;
        }

        var tag = new TagBuilder(elementName)
        {
            InnerHtml = html
        };

        if (string.IsNullOrEmpty(cssClass) == false)
        {
            tag.AddCssClass(cssClass);
        }

        return new MvcHtmlString(tag.ToString());
    }

    private bool PropertyIsContentArea<TModel, TValue>(HtmlHelper<TModel> html, Expression<Func<TModel, TValue>> expression)
    {
        var contentAreaType = typeof(ContentArea);
        var modelMetadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData);

        return contentAreaType.IsAssignableFrom(modelMetadata.ModelType);
    }

    #region Private methods reflected from base class

    private string ResolveTemplateName<TModel, TValue>(HtmlHelper<TModel> html, RouteValueDictionary additionalValues, Expression<Func<TModel, TValue>> expression)
    {
        var modelMetadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData);

        var tag = additionalValues["tag"] as string;

        if (string.IsNullOrEmpty(tag) && modelMetadata != null)
        {
            tag = this.GetTagFromModelMetadata(modelMetadata);
        }

        if (string.IsNullOrEmpty(tag) == false && modelMetadata != null)
        {
            var templateResolver = html.ViewData["templateResolver"] as TemplateResolver ?? ServiceLocator.Current.GetInstance<TemplateResolver>();

            var templateModel = templateResolver.Resolve(
                html.ViewContext.HttpContext,
                modelMetadata.ModelType,
                modelMetadata.Model,
                TemplateTypeCategories.MvcPartialView,
                tag);

            var templateName = this.GetTemplateName(templateModel, html.ViewContext);

            if (string.IsNullOrEmpty(templateName) == false)
            {
                return templateName;
            }
        }

        if (this.DisplayTemplateWithNameExists(html.ViewContext, tag) == false)
        {
            return null;
        }

        return tag;
    }

    private string GetTagFromModelMetadata(ModelMetadata metaData)
    {
        if (metaData == null || metaData.ContainerType == null)
        {
            return null;
        }

        var property = metaData.ContainerType.GetProperty(metaData.PropertyName);

        if (property != null)
        {
            var uIHintAttributes = property.GetCustomAttributes(true).OfType<UIHintAttribute>();

            var uIHintAttribute = uIHintAttributes.FirstOrDefault(a => string.Equals(a.PresentationLayer, "website", StringComparison.OrdinalIgnoreCase));

            if (uIHintAttribute != null)
            {
                return uIHintAttribute.UIHint;
            }

            uIHintAttribute = uIHintAttributes.FirstOrDefault(a => string.IsNullOrEmpty(a.PresentationLayer));

            if (uIHintAttribute != null)
            {
                return uIHintAttribute.UIHint;
            }
        }

        return null;
    }

    private string GetTemplateName(TemplateModel templateModel, ControllerContext viewContext)
    {
        if (templateModel == null)
        {
            return null;
        }

        if (this.DisplayTemplateWithNameExists(viewContext, templateModel.Name) == false)
        {
            return null;
        }

        return templateModel.Name;
    }

    private bool DisplayTemplateWithNameExists(ControllerContext viewContext, string templateName)
    {
        if (string.IsNullOrEmpty(templateName))
        {
            return false;
        }

        var viewEngineResult = this.viewResolver.FindPartialView(viewContext, $"DisplayTemplates/{templateName}");

        if (viewEngineResult == null)
        {
            return false;
        }

        return viewEngineResult.View != null;
    }

    #endregion
}