1

I'm completely new to ASP.NET MVC, I'm writing a blog application that consists of articles. As an admin you have a dashboard where I can add articles:

@model BlogApplication.Models.DashboardViewModel
....
<h3>Add New Article</h3>

@using (Html.BeginForm("AddArticle", "Admin", FormMethod.Post))
{
    @Html.AntiForgeryToken()
    <div class="form-group">
        @Html.LabelFor(m => m.NewArticle.Title, "Title")
        @Html.TextBoxFor(m => m.NewArticle.Title, new { @class = "form-control" })
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.NewArticle.ShortDescription, "Short Description")
        @Html.TextBoxFor(m => m.NewArticle.ShortDescription, new { @class = "form-control" })
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.NewArticle.Content, "Content")
        @Html.TextAreaFor(m => m.NewArticle.Content, new { @class = "form-control", rows = 5 })
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.NewArticle.TagIds, "Tags")
        @Html.ListBoxFor(m => m.NewArticle.TagIds, new MultiSelectList(Model.Tags, "TagId", "TagName"), new { @class = "form-control" })
    </div>
    <button type="submit" class="btn btn-primary">Add Article</button>
}

The action method:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult AddArticle(ArticleViewModel model)
{
    if (ModelState.IsValid)
    {
        var article = new Article
            {
                Title = model.Title,
                Content = model.Content,
                ShortDescription = model.ShortDescription,
                DatePublished = DateTime.Now,
                AuthorId = (int)Session["UserId"]
            };

        _articleService.AddArticle(article, model.TagIds);

        return RedirectToAction("Dashboard");
    }

    // Log validation errors for debugging
    var errors = ModelState.Values.SelectMany(v => v.Errors);

    foreach (var error in errors)
    {
        System.Diagnostics.Debug.WriteLine(error.ErrorMessage);
    }

    var articles = _articleService.GetAllArticles();
    var comments = _commentService.GetAllComments();
    var tags = _tagService.GetAllTags();

    var viewModel = new DashboardViewModel
        {
            Articles = articles,
            Comments = comments,
            Tags = tags,
            NewArticle = model
        };

    return View("Dashboard", viewModel);
}

The model:

public class ArticleViewModel
{
    [Required(ErrorMessage = "The Title field is required.")]
    public string Title { get; set; }

    [Required(ErrorMessage = "The Content field is required.")]
    public string Content { get; set; }

    [Required(ErrorMessage = "The Short Description field is required.")]
    public string ShortDescription { get; set; }

    [Required(ErrorMessage = "The TagIds field is required.")]
    public List<int> TagIds { get; set; }
}

ModelState.IsValid is being set to false. When I check the payload at the network tab on devtools, the data exists.

I don't understand why the validation is not passing. What can I do?

2
  • 1
    The issue is that you use different c# classes for render and form submission. in razor view you have x.NewArticle.Title, so the input name looks like "NewArticle.Title" but you use different c# class in action method Commented Jun 15, 2024 at 15:11
  • What you want is for the Html.LabelFor() methods to return a name="" attribute as though this.Model.NewArticle (not this.Model) was the "root" of the model-binding context. Unfortunately (and annoyingly) ASP.NET never decided to make this easy... Commented Jun 15, 2024 at 15:18

1 Answer 1

2

The solution comes with credit to an article from 2012 (though as you are using ASP.NET MVC instead of ASP.NET Core MVC, the vintange is about right).


First, see here:

@model BlogApplication.Models.DashboardViewModel

The above @model TModel directive means that all IHtmlHelper<TModel> methods, like TextBoxFor and NameFor, will generate HTML name="" attributes where TModel is the root object, even though that's not our actual intent (because the public ActionResult AddArticle(ArticleViewModel model) action will use ASP.NET's form/model binding with ArticleViewModel as the root object type, not DashboardViewModel.

So then your .cshtml has this:

@Html.TextBoxFor(m => m.NewArticle.Title, new { @class = "form-control" })

...which will render this HTML:

<input type="text" name="NewArticle.Title" class="form-control" />

Now observe how we have name="NewArticle.Title" and not name="Title".


Fortunately it is possible to work-around this by adding a single helper method that allow you to specify the exact T for IHtmlHelper<T> regardless of the declared @model type:

First, copy-and-paste this into your project:

(NOTE: Ensure the code lives in a namespace which is imported in your _ViewImports.cshtml because it's an extension-method which only works when the namespace is imported).

public static class WeShouldNotHaveToDoThisOurselves
{
    public static HtmlHelper<T> HtmlHelperFor<T>( this HtmlHelper html, T model )
    {
        if( html is null ) throw new ArgumentNullException( nameof(html) );

        ViewDataDictionary newViewDataDict = new ViewDataDictionary( html.ViewDataContainer.ViewData ) { Model = model };

        ViewContext newViewContext = new ViewContext(
            controllerContext: html.ViewContext.Controller.ControllerContext,
            view             : html.ViewContext.View,
            vieData          : newViewDataDict,
            tempData         : html.ViewContext.TempData,
            writer           : html.ViewContext.Writer
        );
        
        ViewDataContainer viewDataContainer = new ViewDataContainer( newViewContext.ViewData );
        
        return new HtmlHelper<T>( newViewContext, viewDataContainer, html.RouteCollection );
    }
}

Then tweak your .cshtml like so:

  1. Get your replacement IHtmlHelper<ArticleViewModel> in a @{} code-block somewhere before you render any HTML. In this case, we're naming it f to be succint (the "f" is for "form").
  2. Use f.NameFor, f.TextBoxFor, etc instead of Html.NameFor, Html.TextBoxFor.
    • But you only need to do this where Html is being used for model-binding expressions for HTML name="" attributes. In all other places, like Html.BeginForm you can continue to use the original Html (IHtmlHelper<DashboardViewModel>).

...like so:

@model BlogApplication.Models.DashboardViewModel
@{

    IHtmlHelper<ArticleViewModel> f = this.Html.HtmlHelperFor( this.Model.NewArticle );

}

....
<h3>Add New Article</h3>

@using (Html.BeginForm("AddArticle", "Admin", FormMethod.Post))
{
    @Html.AntiForgeryToken()
    <div class="form-group">
        @f.LabelFor(m => m.NewArticle.Title, "Title")
        @f.TextBoxFor(m => m.NewArticle.Title, new { @class = "form-control" })
    </div>
    <div class="form-group">
        @f.LabelFor(m => m.NewArticle.ShortDescription, "Short Description")
        @f.TextBoxFor(m => m.NewArticle.ShortDescription, new { @class = "form-control" })
    </div>
    <div class="form-group">
        @f.LabelFor(m => m.NewArticle.Content, "Content")
        @f.TextAreaFor(m => m.NewArticle.Content, new { @class = "form-control", rows = 5 })
    </div>
    <div class="form-group">
        @f.LabelFor(m => m.NewArticle.TagIds, "Tags")
        @f.ListBoxFor(m => m.NewArticle.TagIds, new MultiSelectList(Model.Tags, "TagId", "TagName"), new { @class = "form-control" })
    </div>
    <button type="submit" class="btn btn-primary">Add Article</button>
}

This technique also works in ASP.NET Core too - provided you continue to use HTML-Helpers - but because ASP.NET Core really prefers you use Tag-Helpers, we're unfortunately quite stuck (there are workarounds and alternatives, but they're kinda gnarly, but fortunately are out-of-scope in this SO post).

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.