Search code examples
c#asp.netasp.net-mvcasp.net-coremodel-validation

ASP.NET Core custom validation creates new instance of model


I'm playing with ASP.NET Core and trying to come up with a UI for a simple word game. You receive a randomly generated long word, and you're expected to submit shorter words from letters provided by the long word.

The application doesn't use a repository of any kind yet, and it simply stores a model instance as a static field in the controller for now.

I'm currently facing a problem where every time a new submitted word is validated, a new game instance is created, which naturally guarantees to render a validation error, because each game provides a new long word.

I must be misunderstanding something about the way model validation works but debugging doesn't give me any better clues than just showing a validation context that comes in with a new long word every time.

I'm stuck, please help.

Here's the controller:

public class HomeController : Controller
{
    private static WordGameModel _model;

    public IActionResult Index()
    {
        if (_model == null)
        {
            _model = new WordGameModel();
        }
        return View(_model);
    }

    [HttpPost]
    public IActionResult Index(WordGameModel incomingModel)
    {
        if (ModelState.IsValid)
        {
            _model.Words.Add(incomingModel.ContainedWordCandidate);
            return RedirectToAction(nameof(Index), _model);
        }
        return View(_model);
    }
}

Game model:

public class WordGameModel
{
    public WordGameModel()
    {
        if (DictionaryModel.Dictionary == null) DictionaryModel.LoadDictionary();
        LongWord = DictionaryModel.GetRandomLongWord();
        Words = new List<string>();
    }

    public string LongWord { get; set; }
    public List<string> Words { get; set; }

    [Required(ErrorMessage = "Empty word is not allowed")]
    [MinLength(5, ErrorMessage = "A word shouldn't be shorter than 5 characters")]
    [MatchesLettersInLongWord]
    [NotSubmittedPreviously]
    public string ContainedWordCandidate { get; set; }

    public bool WordWasNotSubmittedPreviously() => !Words.Contains(ContainedWordCandidate);
    public bool WordMatchesLettersInLongWord()
    {
        if (string.IsNullOrWhiteSpace(ContainedWordCandidate)) return false;
        return ContainedWordCandidate.All(letter => LongWord.Contains(letter));
    }
}

A custom validation attribute where validation fails:

internal class MatchesLettersInLongWord : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        WordGameModel model = (WordGameModel) validationContext.ObjectInstance;

        if (model.WordMatchesLettersInLongWord()) return ValidationResult.Success;

        return new ValidationResult("The submitted word contains characters that the long word doesn't contain");
    }
}

View:

@model WordGameModel

<div class="row">
    <div class="col-md-12">
        <h2>@Model.LongWord</h2>
    </div>
</div>

<div class="row">
    <div class="col-md-6">
        <form id="wordForm" method="post">
            <div>
                <input id="wordInput" asp-for="ContainedWordCandidate"/>
                <input type="submit" name="Add" value="Add"/>
                <span asp-validation-for="ContainedWordCandidate"></span>
            </div>

        </form>
    </div>
</div>

<div class="row">
    <div class="col-md-6">
        <ul>
            @foreach (var word in @Model.Words)
            {
                <li>@word</li>
            }
        </ul>
    </div>
</div>

Thanks.


Solution

  • Your view would need to include a hidden input for LongWord, so that in the POST method so that after your constructor is called by the ModelBinder, the LongWord is set based on the form value (i.e. the value you sent to the view)

    <form id="wordForm" method="post">
        <div>
            <input type="hidden" asp-for="LongWord" /> // add hidden input
            <input id="wordInput" asp-for="ContainedWordCandidate"/>
            <input type="submit" name="Add" value="Add"/>
            <span asp-validation-for="ContainedWordCandidate"></span>
        </div>
    </form>
    

    As a side note, in your post method it should be just return RedirectToAction(nameof(Index)); - the GET method does not (and should not) have a parameter for the model, so there is no point passing it (and it would just create an ugly query string anyway)