Writing line of business applications usually means creating a lot of forms for data entry. Writing the HTML for them over and over again is tedious and also means copy-pasting the layout structure into every single form. Copy-pasting works fine as long as we one is happy with the design, but when it needs to be altered (beyond what’s possible by CSS), all forms in the application need to change. To remedy this, I created a form-entry tag helper. Now creating an entry for a field in a form is as simple as <form-entry asp-for="LocationName" />
.
Using the default scaffolding in Visual Studio, I would get a form that repeats the same pattern over and over again, for each property of the view model.
<form asp-action="Create"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="Name" class="control-label"></label> <input asp-for="Name" class="form-control" /> <span asp-validation-for="Name" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Address" class="control-label"></label> <input asp-for="Address" class="form-control" /> <span asp-validation-for="Address" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="City" class="control-label"></label> <input asp-for="City" class="form-control" /> <span asp-validation-for="City" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-default" /> </div> </form> |
Using my form-entry
tag helper, the code required is substantially less.
<form asp-action="Create"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <form-entry asp-for="Name" /> <form-entry asp-for="Address" /> <form-entry asp-for="City" /> <div class="form-group"> <input type="submit" value="Create" class="btn btn-default"/> </div> </form> |
Design Objectives
The design objectives for the form-entry
tag helper can be summarized as DRY. DRY is short for Don’t Repeat Yourself. One reason for that is to reduce typing. But that is not the most important part. No, the important parts are readability and maintainability. With the form-entry
tag helper the code gets much cleaner and it is easier to see what fields the form are actually made up of.
The other important part is maintainability. Repeating the same html pattern over and over again in all forms in application means spreading the knowledge of how forms look across the application. That knowledge should be in one single place.
Using Razor
I although thought it would be nice to be able to use Razor for the template itself, to easily be able to call the tag helpers for generating labels etc correctly. Unfortunately I didn’t succeed in this objective. The reason is that the tag helpers are built on the assumption that the razor file contains the type of the model. And here we want to be able to use a generic Razor file for all different models in the project.
Reusing Tag Helpers
When the Razor path turned out impossible, I instead looked at calling the tag helpers from code. This too turned out to be hard as it would require generating the right context for a tag helper. But thanks to the layered architecture of the tag helpers reuse was still possible. The built in tag helpers do not do the Html generation themselves. Instead they rely on an IHtmlGenerator
to do that. And the IHtmlGenerator
turned out te be fairly simple to call from my custom tag helper.
The Code
public class FormEntryTagHelper: TagHelper { private readonly IHtmlGenerator htmlGenerator; private readonly HtmlEncoder htmlEncoder; public FormEntryTagHelper(IHtmlGenerator htmlGenerator, HtmlEncoder htmlEncoder) { this.htmlGenerator = htmlGenerator; this.htmlEncoder = htmlEncoder; } private const string ForAttributeName = "asp-for"; [HtmlAttributeName(ForAttributeName)] public ModelExpression For { get; set; } [HtmlAttributeNotBound] [ViewContext] public ViewContext ViewContext { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { output.TagName = "div"; output.TagMode = TagMode.StartTagAndEndTag; output.Attributes.Add("class", "form-group"); using (var writer = new StringWriter()) { WriteLabel(writer); WriteInput(writer); WriteValidation(writer); output.Content.AppendHtml(writer.ToString()); } } private void WriteLabel(TextWriter writer) { var tagBuilder = htmlGenerator.GenerateLabel( ViewContext, For.ModelExplorer, For.Name, labelText: null, htmlAttributes: new { @class = "control-label" }); tagBuilder.WriteTo(writer, htmlEncoder); } private void WriteInput(TextWriter writer) { var tagBuilder = htmlGenerator.GenerateTextBox( ViewContext, For.ModelExplorer, For.Name, value: null, format: null, htmlAttributes: new { @class = "form-control" }); tagBuilder.WriteTo(writer, htmlEncoder); } private void WriteValidation(TextWriter writer) { var tagBuilder = htmlGenerator.GenerateValidationMessage( ViewContext, For.ModelExplorer, For.Name, message: null, tag: null, htmlAttributes: new { @class = "text-danger" }); tagBuilder.WriteTo(writer, htmlEncoder); } } |
Perfect! Thank you so much!
I was trying to do the same
I suggest change value in Write:
Good job. Buy what if you need to change the type of the input (let’s say you’ve got dates, comboboxes ?)
Thanks a lot.