Wednesday, December 4, 2013

Inline markup delegates and razor helper delegate wars

So I had some markup that was being duplicated all over the place in MVC.

This is mostly boilerplate bootstrap paneling, with masonry on top.

How would you provide a generic razor to meet bootstrap/masonry without creating a bunch of classes or partials to account for special case(s)?  How in razor would you write a method that can take inline markup (for example any @<text> call)?

 One way is @helper Take note that HelperResult's documentation says specifically it is not intended to be used directly in your code. So I headed in down the path of inline razor helper delegates:



@{
    var scope = (Web.ViewModels.ScopeModel)ViewBag.Scope;
}
@helper progressBar(int completion)
{
    <div class="progress progress-striped">
        <div class="progress-bar" role="progressbar"
             aria-valuenow="@completion.ToString()" aria-valuemax="100" aria-valuemin="0"
             style="width : @completion%;">
            <span class="sr-only">@completion% Complete</span>
        </div>
    </div>
}
@helper widget(Func<dynamic,HelperResult> title,Func<dynamic,HelperResult> body,Func<dynamic, HelperResult> footer)
{
    <div class="widget-wrapper md-widget">
        <div class="panel panel-default">
            <div class="panel-heading">
                <h3 class="panel-title">@title(null)</h3>
            </div>
            <div class="panel-body text-center">
                @body(null)
            </div>
            <div class="panel-footer">
                @footer(null)
                <div class="clearfix"></div>
            </div>
        </div>
        <div class="clearfix"></div>
    </div>
}
<div class="tile-layout">
    <div class="row">
        <div class="col-md-12">
            <div class="widget-container">
                @widget(@<text>Profiles</text>, @<text>
                    <p>Your profile is @scope.Member.Completion% complete! To complete your profile click on the incomplete profiles below.</p>
                        @progressBar(scope.Member.Completion)
                </text>,
                @<text>
                    <a class="btn btn-default pull-right" href="#" role="button" data-toggle="modal" data-target="#pending-rewards-info">Learn more &raquo;</a>
                </text>)
                @foreach (var p in scope.Member.Profiles)
                {
                    @widget(@<text>
                    <img src="@p.Icon" /><a href="">@p.Name Profile</a><a href="#" class="btn" rel="tooltip" data-placement="bottom" title="" class="remaining" data-original-title="Number of Unanswered Questions for this Profile">@p.UnansweredCount</a>
                </text>, @<text>
                @progressBar(p.CompletionLevel)
                </text>,@<text>
                </text>)
                }
            </div>
            <div class="clearfix"></div>
        </div>
    </div>
</div>

holy cow that's a mess. To boot, formatting the document would actually break it, it would remove some tags and add others in inappropriate places.

calling @<text> is a way to pass markup as a delegate to a method so that you can provide parts and it can control where those go( or if they go).

 So after a few hours of trying to improve it and find out what else I could do:
@{
    Layout = MVC.Shared.Views._LayoutTiles;
    var scope = (Web.ViewModels.ScopeModel)ViewBag.Scope;
}
<div class="tile-layout">
    <div class="row">
        <div class="col-md-12">
            <div class="widget-container">
                @Html.Widget("lg", "panel-default panel-lead", @<h3 class="panel-title">Profiles</h3>,
                                           _ => profilesHeaderBody(scope.Member.Completion))

            </div>
            <div class="widget-container">
                @foreach (var p in scope.Member.Profiles)
                {
                    @Html.Widget(addPanelClasses:"panel-default",header: _ => profileHeader(p),body: _ => profileBody(p))
                }
            </div>
            <div class="clearfix"></div>
        </div>
    </div>
</div>
@helper profileHeader(Web.ViewModels.Interfaces.Profile p)
{
    <h3 class="panel-title">
        <img style="width:33px;height:24px;margin-top:-3px;" src="@p.Icon" />&nbsp;<a href="@Url.Action(p.Name)">@p.Name Profile</a><span class="pull-right"><small><a href="#">Edit</a></small></span>
    </h3>
}
@helper profileBody(Web.ViewModels.Interfaces.Profile p)
{
    @progressBar(p.CompletionLevel, "", "margin-bottom:0;")
    <span class="pull-left"><small>Completion: @p.CompletionLevel%</small></span>
    <span class="pull-right"><small>Missing answers: @p.UnansweredCount</small></span>
}
@helper profilesHeaderBody(int completion)
{
    <p class="lead"><strong>Total completion: @completion%</strong></p>
    @progressBar(completion, "progress-bar-success", "max-width:500px;margin-left:auto;margin-right:auto;")
}
Still unwanted syntax elements, and we have added a small helper method, and a partial view.
@model CVS.Member.Web.ViewModels.Shared.WidgetViewModel
<div class="widget-wrapper @Model.WidgetSize-widget" style="@Model.WidgetStyle">
    <div class="panel @Model.AddPanelClasses">
        @if (Model.HeaderMarkup != null)
        {
            <div class="panel-heading">
                @Model.HeaderMarkup(null)
            </div>
        }
        @if (Model.Body != null)
        {
            <div class="panel-body text-center">
                @Model.Body(null)
            </div>
        }
        @if (Model.UnwrappedContent!=null)
        {
            @Model.UnwrappedContent(null)
        }
        
        @if (Model.Footer != null)
        {
            <div class="panel-footer">
                @Model.Footer(null)
                <div class="clearfix"></div>
            </div>
        }
    </div>
    <div class="clearfix"></div>
</div>

Every possible combination of a Func<> wound up with extra work in the calling site. Here's the model class:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.WebPages;

namespace Web.ViewModels.Shared
{
    public class WidgetViewModel
    {

        public string HeaderClass { get; set; }
        public string AddPanelClasses { get; set; }
        public string WidgetSize { get; set; }
        public Func<dynamic, HelperResult> HeaderMarkup { get; set; }
        public Func<dynamic, HelperResult> Body { get; set; }
        public Func<dynamic, HelperResult> UnwrappedContent { get; set; }
        public Func<dynamic, HelperResult> Footer { get; set; }
        public string WidgetStyle { get; set; }

        public WidgetViewModel()
        {
            WidgetSize = "md";
        }


    }
}
So to call that with either inline helpers or inline markup:
<div class="tile-layout">
    <div class="row">
        <div class="col-md-12">
            <div class="widget-container">
                @Html.Widget("lg", "panel-default panel-lead", @<h3 class="panel-title">Profiles</h3>,
                                           _ => profilesHeaderBody(scope.Member.Completion))

            </div>
            <div class="widget-container">
                @foreach (var p in scope.Member.Profiles)
                {
                    @Html.Widget(addPanelClasses:"panel-default",header: _ => profileHeader(p),body: _ => profileBody(p))
                }
            </div>
            <div class="clearfix"></div>
        </div>
    </div>
</div>
See how I have to delegate the call to a delegate? and still haven't gotten around the problem of short sweet stuff for inline markup.
Perhaps we can eliminate that somehow!

public static Func<HelperResult> GetMarkupResult(this HtmlHelper helper, Func<dynamic, HelperResult> markup)
        {
            return ()=>markup(null);
        }

Solution

Finally what broke the linguistics barrier... looking at HelperResult and seeing that it already implements IHtmlString. I was concerned that it was going to use ToString and re-encode the markup. Reworked the helper and model.

        public static IHtmlString GetMarkupResult(this HtmlHelper helper, Func<object, HelperResult> markup)
        {
            return markup(null);
        }
Web.ViewModels.Shared
{
    public class WidgetViewModel
    {
        public WidgetViewModel(string headerText = null)
        {
            WidgetSize = "md";
            if (headerText.IsNullOrEmpty() == false)
                HeaderMarkup = new MvcHtmlString(headerText);
        }
        public string HeaderClass { get; set; }
        public string AddPanelClasses { get; set; }
        public string WidgetSize { get; set; }
        public IHtmlString HeaderMarkup { get; set; }
        public IHtmlString Body { get; set; }
        public IHtmlString UnwrappedContent { get; set; }
        public IHtmlString Footer { get; set; }
        public string WidgetStyle { get; set; }

    }
}
Here's the partial, it is cleaner as well
<div class="widget-wrapper @Model.WidgetSize-widget" style="@Model.WidgetStyle">
    <div class="panel panel-default @Model.AddPanelClasses">
        @if (Model.HeaderMarkup != null)
        {
            <div class="panel-heading">
                @Model.HeaderMarkup
            </div>
        }
        @if (Model.Body != null)
        {
            <div class="panel-body text-center">
                @Model.Body
            </div>
        }
        @if (Model.UnwrappedContent!=null)
        {
            @Model.UnwrappedContent
        }
        
        @if (Model.Footer != null)
        {
            <div class="panel-footer">
                @Model.Footer
                <div class="clearfix"></div>
            </div>
        }
    </div>
    <div class="clearfix"></div>
</div>
A cleaner result for the caller and an improved api for the system:
<div class="tile-layout">
    <div class="row">
        <div class="col-md-12">
            <div class="widget-container">
                @Html.Widget(new WidgetViewModel
           {
               WidgetSize = "lg",
               AddPanelClasses = "panel-default panel-lead",
               HeaderMarkup = Html.GetMarkupResult(@<h3 class="panel-title">Profiles</h3>),
               Body = profilesHeaderBody(scope.Member.Completion)
           })

            </div>
            <div class="widget-container">
                @foreach (var p in scope.Member.Profiles)
                {
                    @Html.Widget(new WidgetViewModel
                       {
                           HeaderMarkup =profileHeader(p),
                           Body = profileBody(p)
                       })
                }
            </div>
            <div class="clearfix"></div>
        </div>
    </div>
</div>

No comments:

Post a Comment