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
}

Posted in: Comments

Coming from WebForms development I was a bit annoyed with the inconsistent behavior of the PropertyFor() Html Helper. In WebForms properties are always wrapped with an element, but in MVC this is only true in edit mode. Please see Joel’s excelent post about PropertyFor() and what gets actually rendered to the client.

Conclusion: Properties always gets wrapped with an element in edit mode, but never in view mode. However this is only true for non ContentArea properties, which are not really the problem here.

Why is this a problem you might ask? Well this might actually break your design, especially in edit mode where the extra wrapping element might add styling that you don’t want. So how can we fix this?

One solution is to not use PropertyFor() at all and instead use DisplayFor() or just @Model.YourProperty. But then you need to add the editing attributes manually to a parent element but then that parent will always get rendered in view mode, even though the property doesn’t have a value. You can fix that by adding an if-statement around the parent and the property and then check whether we are in edit mode and/or the property has a value (we always need to render the parent in edit mode). Not so nice, and a lot of unnecessary code and logic in the view:

@if (EPiServer.Editor.PageEditing.PageIsInEditMode || Model.CurrentPage.PageHeading != null)
{
	<h2 @Html.EditAttributes(x => x.CurrentPage.PageHeading)>
		@Html.DisplayFor(x => x.CurrentPage.PageHeading)
	</h2>
}

Replacing the PropertyRenderer was the easist way I found to fix this behavior. Please let me know in the comment section if you have a better way of solving this! It was actually really easy:

public class SitePropertyRenderer : PropertyRenderer
{
    protected override MvcHtmlString GetHtmlForDefaultMode<TModel, TValue>(string propertyName, string templateName, string elementName, string elementCssClass, Func<string, MvcHtmlString> displayForAction)
    {
        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(elementCssClass) == false)
        {
            tag.AddCssClass(elementCssClass);
        }

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

We only have to override  how the property is rendered in view mode and always add the wrapping element. We also need to replace the default renderer in the container with our own, which can be done in an initializable module:

[InitializableModule]
public class ContainerConfigurableModule : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        context.Container.Configure(container =>
            container.For<PropertyRenderer>().Use<SitePropertyRenderer>());
    }
}

Now we can write like this in the view instead, and the wrapper gets always rendered, but only in view mode if the property has a value:

@Html.PropertyFor(x => x.CurrentPage.PageHeading, new { CustomTag = "h2" })

This will probably not be an issue in MVC 6, hopefully in a near future, where we can use Tag Helpers instead.

I have some more blog post in the pipeline on the same topic; keeping the views and output clean. So stay tuned!