This is a classic question for any technology stack. To answer this question, I keep a couple things in mind:
- Don't Repeat Yourself (which can be more difficult with WebForms)
- Do one thing, and do it well
I've found client side functionality falls into a couple of categories:
- Form validations, which are often extensions of Business Rules that should be managed in back end code
- Usability enhancements, such as drop down menus, automatically capitalizing text when moving focus away from a text field, etc.
- User interaction management, which is likely driven by business rules that are not easily done on the back end.
(Note: The code below probably has a few bugs in it, but it should give you the main idea)
Form Validations With ASP.NET WebForms
This has been the area causing the most pain for me. I'm currently experimenting using FluentValidation with WebForms, and it's actually going pretty well. My best piece of advice regarding validations: Don't use the <asp:Foo /> validators! This is the reason that people complain about WebForms being a copy-and-paste framework. It doesn't have to be that way. Before a quick code example, don't use Data[Set|Table|Row]s either! You get all of the data, but none of the behavior. Use an ORM like Entity Framework or NHibernate, and have all of your ASP pages deal with entity classes, because then you can use something like FluentValidation:
App_Code/Models/Entities/Post.cs
namespace Project.Models.Entities
{
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public string Body { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
}
}
App_Code/Models/Validators/PostValidator.cs
using FluentValidation;
using Project.Models.Entities;
namespace Project.Models.Validators
{
public class PostValidator : AbstractValidator<Post>
{
public PostValidator()
{
RuleFor(p => p.Title)
.NotEmpty()
.Length(1, 200);
RuleFor(p => p.Body)
.NotEmpty();
}
}
}
Once you have your basic entities and validators, use them in your code behind:
UserControls/PostControl.ascx.cs
namespace Project.UserControls
{
public class PostControl : System.Web.UI.UserControl
{
protected void Page_Load(object sender, EventArgs e)
{
if (Page.IsPostBack)
{
PostValidator validator = new PostValidator();
Post entity = new Post()
{
// Map form fields to entity properties
Id = Convert.ToInt32(PostId.Value),
Title = PostTitle.Text.Trim(),
Body = PostBody.Text.Trim()
};
ValidationResult results = validator.Validate(entity);
if (results.IsValid)
{
// Save to the database and continue to the next page
}
else
{
BulletedList summary = (BulletedList)FindControl("ErrorSummary");
// Display errors to the user
foreach (var failure in results.Errors)
{
Label errorMessage = FindControl(failure.PropertyName + "Error") as Label;
if (errorMessage == null)
{
summary.Items.Add(new ListItem(failure.ErrorMessage));
}
else
{
errorMessage.Text = failure.ErrorMessage;
}
}
}
}
else
{
// Display form
}
}
...
}
}
UserControls/PostControl.ascx
<asp:BulletedList ID="ErrorSummary" runat="server" CssClass="Error-Summary" />
<p>
<asp:Label ID="PostTitleLabel" AssociatedControlID="PostTitle" runat="server">* Title:</asp:Label>
<asp:TextBox ID="PostTitle" runat="server" />
<asp:Label ID="PostTitleError" runat="server" CssClass="Error" />
</p>
<p>
<asp:Label ID="PostBodyLabel" AssociatedControlID="PostBody" runat="server">* Body:</asp:Label>
<asp:TextBox ID="PostBody" runat="server" TextMode="MultiLine" />
<asp:Label ID="PostBodyError" runat="server" CssClass="Error" />
</p>
<asp:HiddenField ID="PostId" runat="server" />
Programmatically Adding Client Side Validations
Now that we have a solid foundation in C#, you can add HTML attributes to each of the form fields and use jQuery Validate to trigger some of the front end validations. You can programatically loop through the FluentValidation rules:
PostValidator validator = new PostValidator();
foreach (var rule in validator.AsEnumerable())
{
propertyRule = rule as FluentValidation.Internal.PropertyRule;
if (propertyRule == null)
continue;
WebControl control = (WebControl)FindControl("Post" + propertyRule.PropertyName);
foreach (var x in rule.Validators)
{
if (x is FluentValidation.Validators.NotEmptyValidator)
{
control.Attributes["required"] = "required";
}
else if (x is FluentValidation.Validators.MaximumLengthValidator)
{
var a = (FluentValidation.Validators.MaximumLengthValidator)x;
control.Attributes["size"] = a.Max.ToString();
control.Attributes["minlength"] = a.Min.ToString();
control.Attributes["maxlength"] = a.Max.ToString();
}
...
}
}
Complex, Multi Field Validations
Any validation that requires data from more than one field should not be handled on the client. Do this in C#. Trying to cobble this together in HTML and JavaScript on an ASP page becomes cumbersome and is not enough of a benefit to justify the added overhead and maintenance issues.
Usability Enhancements
These JavaScript snippets assist users, and do little to implement business rules. On an application I work on, whenever the user moves focus away from a text box, each word should be capitalized so "foo bar" becomes "Foo Bar". JavaScript and event delegation to the rescue:
Scripts/foo.js (imported on each page)
$(document).on("focusout", "input[type=text][data-capitalize-disabled^=true]", function(event) {
event.target.value = event.target.value.replace(/(^|\s+)[a-z]/g, function(match, $1) {
return $1.toUpperCase();
});
});
To disable this behavior:
Code Behind:
PostTitle.Attributes["data-capitalize-disabled"] = "true";
ASP:
<asp:TextBox ... data-capitalize-disabled="true" />
If you can manage this in the ASP file, now you've completely decoupled the front end and back end code!
User Interaction Management
This is the 800 Pound Gorilla of front end development. I like to use a "widget pattern" here, where you write a JavaScript class to encompass the behavior and use HTML attributes and class names as hooks for JavaScript to do its thing.
Scripts/FooWidget.js
function FooWidget(element) {
this.$element = $(element);
this.fillOptions = this.fillOptions.bind(this);
this.$element.on("click", "[data-action=fillOptions]", this.fillOptions);
}
FooWidget.prototype = {
constructor: FooWidget,
fillOptions: function(event) {
// make ajax request:
var select = this.$element.find("select:first")[0],
option = null;
option = document.createElement("option");
option.value = "...";
option.text = "...";
select.appendChild(option);
...
},
focus: function() {
this.$element.find(":input:first").focus();
}
};
And in your ASP file:
<asp:Panel ID="FooPanel" runat="server">
<button type="button" data-action="fillOptions">Fill Options</button>
<asp:DropDownList ID="OptionsDropdown" runat="server" />
</asp:Panel>
<script type="text/javascript">
var foo = new FooWidget("<%# FooPanel.ClientId %>");
</script>
Again, the object here is to keep JavaScript and HTML tied together, and not put any JavaScript in C#.