Ingmar
Ingmar

Reputation: 1447

How to implement two forms with separate BindProperties in Razor Pages?

I am using ASP.NET Core 2 with Razor Pages and I am trying to have two forms with separate properties (BindProperty) on one page.

@page
@model mfa.Web.Pages.TwoFormsModel
@{
    Layout = null;
}

<form method="post">
    <input asp-for="ProductName" />
    <span asp-validation-for="ProductName" class="text-danger"></span>
    <button type="submit" asp-page-handler="Product">Save product</button>
</form>

<form method="post">
    <input asp-for="MakerName" />
    <span asp-validation-for="MakerName" class="text-danger"></span>
    <button type="submit" asp-page-handler="Maker">Save maker</button>
</form>

And the corresponding PageModel:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace mfa.Web.Pages
{
    public class TwoFormsModel : PageModel
    {
        [BindProperty]
        [Required]
        public string ProductName { get; set; }

        [BindProperty]
        [Required]
        public string MakerName { get; set; }

        public async Task<IActionResult> OnPostProductAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            return Page();
        }

        public async Task<IActionResult> OnPostMakerAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            return Page();
        }
    }
}

Hitting any of the two submit buttons brings me in the corresponding post handler. Both "ProdutName" and "MakerName" are populated corectly with whatever I typed in the corresponding input fields. So far, so good.

But: ModelState.IsValid() always returns true - no matter if the value of the corresponding property has a value or not. ModelState.IsValid() is true even when both properties are null.

Also: OnPostProductAsync() should only validate "ProductName" and accordingly OnPostMakerAsync() should only validate "MakerName".

Can this be done at all? Or am I asking too much from Razor Pages? There are plenty of blogs and tutorials that show you how to have two forms on one page ... but they are all using the same model. I need different models!

Upvotes: 21

Views: 16313

Answers (5)

lonix
lonix

Reputation: 20957

No hacks:

MyPage.cshtml.cs

public FooDto Foo { get; set; }   // do not use `[BindProperty]`
public BarDto Bar { get; set; }   // ''

public async Task<IActionResult> OnGet()
{
  Foo = new FooDto(await LoadFoo());
  Bar = new BarDto(await LoadBar());
}

public async Task<IActionResult> OnPostFoo(FooDto foo)
{
  Debug.Assert(nameof(foo).ToLower() == nameof(Foo).ToLower());
  if (!ModelState.IsValid) return Page();
  // use `foo` rather than `Foo` ...
}

public async Task<IActionResult> OnPostBar(BarDto bar)
{
  Debug.Assert(nameof(bar).ToLower() == nameof(Bar).ToLower());
  if (!ModelState.IsValid) return Page();
  // use `bar` rather than `Bar` ...
}

MyPage.cshtml

<form method="post" asp-page-handler=(nameof(MyPageModel.OnPostFoo).Replace("OnPost", ""))>
  <!-- use `Model.Foo` ... -->
  <button type="submit">Save</button>
</form>

<form method="post" asp-page-handler=(nameof(MyPageModel.OnPostBar).Replace("OnPost", ""))>
  <!-- use `Model.Bar` ... -->
  <button type="submit">Save</button>
</form>

Notes:

  • Do not bind models using [BindProperty] as that is the source of the problem; rather bind them within the handler methods
  • Validation will work as expected, as will default values
  • OPTIONAL: In code: the model name and argument name must match; use an assertion and strong typing to ensure they always match
  • OPTIONAL: In markup: the handler's unprefixed/unpostfixed name must match that in asp-page-handler; use strong typing to ensure they always match

Upvotes: 0

&#205;talo Silveira
&#205;talo Silveira

Reputation: 19

I was facing the same problem and that was my solution.

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;
    [BindProperty]
    public IndexSubscribeInputModel SubscribeInput { get; set; }
    [BindProperty]
    public IndexContactInputModel ContactInput { get; set; }
    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    public void OnGet()
    {
        SubscribeInput = new IndexSubscribeInputModel();
        ContactInput = new IndexContactInputModel();
    }

    public void OnPostSubscribe()
    {
        if (IsValid(SubscribeInput))
        {
            return;
        }
    }

    public void OnPostContact()
    {
        if (IsValid(ContactInput))
        {
            return;
        }
    }

    public class IndexSubscribeInputModel
    {
        [Required(AllowEmptyStrings =false, ErrorMessage ="{0} é obrigatório!")]
        public string Email { get; set; }
    }
    public class IndexContactInputModel
    {
        [Required(AllowEmptyStrings = false, ErrorMessage = "{0} é obrigatório!")]
        public string Email { get; set; }

        [Required(AllowEmptyStrings = false, ErrorMessage = "{0} é obrigatório!")]
        public string Message { get; set; }
    }

    private bool IsValid<T>(T inputModel)
    {
        var property = this.GetType().GetProperties().Where(x => x.PropertyType == inputModel.GetType()).FirstOrDefault();

        var hasErros = ModelState.Values
            .Where(value => value.GetType().GetProperty("Key").GetValue(value).ToString().Contains(property.Name))
            .Any(value =>value.Errors.Any());

        return !hasErros;
    }

I'll probably put the "IsValid" method in a PageModelExtensions class, so it'll be more fancy.

Hope this help someone...

Upvotes: 1

Michael Dzuba
Michael Dzuba

Reputation: 131

One more solution pretty close ...

public static class ModelStateExtensions
{
    public static ModelStateDictionary MarkAllFieldsAsSkipped(this ModelStateDictionary modelState)
    {
        foreach(var state in modelState.Select(x => x.Value))
        {
            state.Errors.Clear();
            state.ValidationState = ModelValidationState.Skipped;
        }
        return modelState;
    }
}

public class IndexModel : PageModel 
{
    public class Form1Model {
        // ...
    }
    public class Form2Model {
        // ...
    }

    [BindProperty]
    public Form1Model Form1 { get; set; }

    [BindProperty]
    public Form2Model Form2 { get; set; }

    public async Task<IActionResult> OnPostAsync()
    {
        ModelState.MarkAllFieldsAsSkipped();
        if (!TryValidateModel(Form1, nameof(Form1)))
        {
            return Page();
        }
        // ...
    }
}

Upvotes: 3

AvnerSo
AvnerSo

Reputation: 1727

My solution isn't very elegant, but it doesn't require you to manually do the validations. You can keep the [Required] annotations.

Your PageModel will look like this -

    private void ClearFieldErrors(Func<string, bool> predicate)
    {
        foreach (var field in ModelState)
        {
            if (field.Value.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
            {
                if (predicate(field.Key))
                {
                    field.Value.ValidationState = Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Valid;
                }
            }
        }
    }

    public async Task<IActionResult> OnPostProductAsync()
    {
        ClearFieldErrors(key => key.Contains("MakerName"));
        if (!ModelState.IsValid)
        {
            return Page();
        }

        return Page();
    }

    public async Task<IActionResult> OnPostMakerAsync()
    {
        ClearFieldErrors(key => key.Contains("ProductName"));
        if (!ModelState.IsValid)
        {
            return Page();
        }

        return Page();
    }

Not the best idea because you need to compare the binded field names to strings. I used Contains because the field keys are inconsistent and sometimes contain a prefix. Was good enough for me because I had small forms, with distinct field names.

Upvotes: 4

pitaridis
pitaridis

Reputation: 2983

In order to make the validation work properly you will have to create a view model which will contain the two properties and define the [Required] for each of the properties that you want to check but because you have two different forms with different validation it is not going to work because if both values are defined as required then when you will try to validate the Product it will validate the Maker as well which will not have a value.

What you can do is to make the check yourself. For example the OnPostProduct can have the following code:

public async Task<IActionResult> OnPostProductAsync()
{
    if (string.IsNullOrEmpty(ProductName))
    {
        ModelState.AddModelError("ProductName", "This field is a required field.");
        return Page();
    }

    // if you reach this point this means that you have data in ProductName in order to continue

    return Page();
}

Upvotes: 5

Related Questions