Model Binding in ASP.NET Core Razor Pages - c#

I have a question about model binding in ASP.NET Core Razor pages. I've spent a lot of time reading the Microsoft documentation, Stackoverflow posts and learnrazorpages.com, but haven't quite found the answer I'm looking for. So below is a stripped down version of three classes I have. As you can see, both Client and ReferralPerson contain Person objects and Client also contains a ReferralPerson object. On my razor page (the data entry form), I intentionally do not show all of the fields for the Person object that is part of the ReferralPerson. There are also other properties of the ReferralPerson object that are not shown such as NumberOfReferrals. If a user recalls a record, modifies some of the data and then saves (Post), only those properties that I have included inside of the element end up being populated in the model in the OnPost (OnPostSave). I understand why this is. My question is do I have to include every single property, individually on the page in order for those properties to be populated in the OnPost event? If you look at my Razor page code below, you will see that I add a number of hidden fields at the top of the element. Do I need to do that for every single property that isn't visible or included somewhere else inside that form? Ideally I would just be able to do something like the following to include the entire object versus having hidden fields for every single property. I just want to make sure there isn't something I'm not aware of in terms of how this can or should be handled.
<input type="hidden" asp-for="Client.ReferralPerson" />
Here are the C# classes (model)
public class Person
{
public int ID { get; set; }
[Required, StringLength(50)]
public string FirstName { get; set; } = "N/A";
[Required, StringLength(50)]
public string LastName { get; set; } = "N/A";
[StringLength(30)]
public string PhoneNumberPrimary { get; set; }
[StringLength(30)]
public string PhoneNumberSecondary { get; set; }
[StringLength(50)]
public string Address { get; set; }
[StringLength(50)]
public string EmailAddress { get; set; }
}
public class Client
{
public int ID { get; set; }
public Person Person { get; set; }
public DateTime InitalContactDate { get; set; }
[Required, StringLength(100)]
public string ReasonForVisit { get; set; }
public ReferralPerson ReferralPerson { get; set; }
//other properties removed for brevity
}
public class ReferralPerson
{
public int ID { get; set; }
public Person Person { get; set; }
[StringLength(100)]
public string BusinessName { get; set; }
public int NumberOfReferrals { get; set; }
}
Here's a simplified version of my Razor page:
<form class="needs-validation p-2" novalidate="" method="post">
<input type="hidden" asp-for="Client.ID" />
<input type="hidden" asp-for="Client.Person.ID" />
<input type="hidden" asp-for="Client.ContactType.ID" />
<input type="hidden" asp-for="Client.ReferralPerson.ID" />
<input type="hidden" asp-for="Client.ReferralPerson.Person.ID" />
<div class="row mb-3">
<div class="col-sm-6">
<label asp-for="Client.Person.FirstName" class="form-label">First name</label>
<input type="text" class="form-control" asp-for="Client.Person.FirstName">
<span asp-validation-for="Client.Person.FirstName" class="text-danger font-weight-bold"></span>
</div>
<div class="col-sm-6">
<label asp-for="Client.Person.LastName" class="form-label">Last name</label>
<input type="text" class="form-control" asp-for="Client.Person.LastName">
<span asp-validation-for="Client.Person.LastName" class="text-danger font-weight-bold"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-6">
<label for="type" class="form-label stacked-justify-left">Date</label>
<input type="date" class="form-control" asp-for="Client.InitalContactDate">
<span asp-validation-for="Client.InitalContactDate" class="text-danger font-weight-bold"></span>
</div>
<div class="col-6">
<label for="email" class="form-label">Email <span class="text-muted">(Optional)</span></label>
<input type="email" class="form-control" placeholder="you#example.com" asp-for="Client.Person.EmailAddress">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label for="primaryPhone" class="form-label">Phone # (Primary)</label>
<input type="text" class="form-control" placeholder="Phone Number" asp-for="Client.Person.PhoneNumberPrimary">
</div>
<div class="col-6">
<label for="secodaryPhone" class="form-label">Phone # (Secondary)</label>
<input type="text" class="form-control" placeholder="Phone Number" asp-for="Client.Person.PhoneNumberSecondary">
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<label for="address" class="form-label">Address</label>
<input type="text" class="form-control" placeholder="1234 Main St" asp-for="Client.Person.Address">
</div>
</div>
<div class="row mb-3" id="ReferralPersonDetails" style="display:none">
<div class="col-sm-6">
<label asp-for="Client.ReferralPerson.Person.FirstName" class="form-label">Referral By First name</label>
<input type="text" class="form-control" asp-for="Client.ReferralPerson.Person.FirstName">
</div>
<div class="col-sm-6">
<label asp-for="Client.ReferralPerson.Person.LastName" class="form-label">Referral By Last name</label>
<input type="text" class="form-control" asp-for="Client.ReferralPerson.Person.LastName">
</div>
</div>
<hr class="my-4">
<div class="row mb-3 ml-1 mr-1">
<hr class="my-4">
<button type="submit" class="w-100 btn btn-primary btn-lg" asp-page-handler="Save">Save</button>
</div>
</form>

Model binding is based on the name of properties in model, If you don't use
<input asp-for="xxx">
to bind the specified property, After submitting the form, These properties will not be populated in post method. So if you don't bind all properties, those that are not bound will not be populated in the post method.
But in your data form, I think you just want to let the user modify some properties. So in my opinion, You can create a viewmodel to include the properties whitch you want to change, In this method you don't need to bind all the properties in the form. Then in your controller, You just need to replace the original data with the data in the viewmodel.

You don't need to do the same with all id fields. I guess you are trying to edit the information of Client and ReferralPerson, adding the .Person.ID fields is not necessary because surely your database already has information about Person in each Client or ReferralPerson, get it out in controller process this action then edit it with data from the form.

Related

ASP.NET Core 6 MVC page to create object with list of complex objects

I have the following model, which needs to be created via a page:
public class Exercise
{
public Guid Id{ get; set; }
public string Name { get; set; }
public string Description{ get; set; }
public ICollection<ExerciseCategory> Categories
...
public ICollection<Link> Links { get; set; }
}
public class ExerciseCategory
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
public class Link
{
public string Name { get; set; }
public string Value { get; set; }
}
I have a view that lets me create this object, but I miss a form control for the property Exercise.Links:
<form id="profile-form" method="post">
<div class="row">
<div class="col-12 p-sm-2 p-md-3 p-lg-5 bg-custom-white rounded-4">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-floating mt-2 mb-2">
<input asp-for="Input.Name" class="form-control" />
<label asp-for="Input.Name" class="form-label"></label>
<span asp-validation-for="Input.Name" class="text-danger"></span>
</div>
<div class="form-floating mt-2 mb-2">
<input asp-for="Input.Description" class="form-control" />
<label asp-for="Input.Description" class="form-label"></label>
<span asp-validation-for="Input.Description" class="text-danger"></span>
</div>
<div class="form-floating mt-2 mb-2">
<select asp-for="Input.CategoryIds"
asp-items="Model.Input.Categories" class="form-control"></select>
<label asp-for="Input.Categories" class="form-label"></label>
</div>
<button id="create-exercise-button" type="submit" class="w-100 btn btn-lg btn-primary">Create</button>
</div>
</div>
</form>
The property Exercise.Categories is easy as Category is an entity which is created separately, so I just have a dropdown listing the existing categories.
Link is just a value object that is not persisted on its own. Therefore, the user should be able to create/remove/edit Link from the Exercise create/edit page.
I have tried the following:
#for (var i = 0; i < Model.Input.Links.Count; i++)
{
<div>
<input asp-for="Input.Links[i].Name">
<input asp-for="Input.Links[i].Value">
</div>
}
Which results in the following HTML:
<div>
<input type="text" id="Input_Links_0__Name" name="Input.Links[0].Name" value="some link">
<input type="text" id="Input_Links_0__Value" name="Input.Links[0].Value" value="https://somelink.com">
</div>
<div>
<input type="text" id="Input_Links_1__Name" name="Input.Links[1].Name" value="some link1">
<input type="text" id="Input_Links_1__Value" name="Input.Links[1].Value" value="https://somelink1.com">
</div>
But this doesn't populate the Input.Links property when posting back.
I started thinking that the problem is the fact that my list is a list of Link rather than a list of primitive types. In order to test this, I added a test List<string> to input model called Links1 and tried to populate that list as I did before:
<div class="form-floating mt-2 mb-2">
<input asp-for="Input.Links1[i]" data-val="true" class="form-control" />
<label asp-for="Input.Links1[i]" class="form-label"></label>
</div>
This works. I get the populated list back in the controller after submitting the form, which confirms the problem is the List<Link>
How can I add a form control for populating the property Exercise.Link (which is a List<Link>)?
After reading this answer to another question, I figured out what the problem was:
The class Link, on which the property Exercise.Links depends, has no setter for its Link.Name and Link.Value properties, so the binder cannot populate them when you submit the form.
This is due to Link being a domain model. The solution in this case was to create a presentation layer model that has public {get; set;} and use that on the form instead of the domain model.
So, to answer the question properly, this is how you could write a form control to edit a List<Link>:
#for (var i = 0; i < Model.Input.Links.Count; i++)
{
<div>
<input asp-for="Input.Links[i].Name">
<input asp-for="Input.Links[i].Value">
</div>
}

Validate a single field in Complex Type Model in Blazor

I have a Complex Model like this
public class MyModel
{
[ValidateComplexType]
public Student Student { get; set; } = new Student();
}
public class Student
{
[Required]
public string Name { get; set; }
[Required]
public string Subject { get; set; }
}
and my blazor code is like this
<EditForm Model="this.Model" OnValidSubmit="Submit" #ref="this.myEditForm">
<ObjectGraphDataAnnotationsValidator />
<div class="form-group row">
<label for="name" class="col-md-2 col-form-label">Name</label>
<div class="col-md-10">
<InputText id="name" class="form-control" #bind-Value="this.Model.Student.Name" />
<ValidationMessage For="#(() => this.Model.Student.Name)" />
</div>
</div>
<div class="form-group row">
<label for="supplier" class="col-md-2 col-form-label">Subject</label>
<div class="col-md-10">
<InputText id="supplier" class="form-control" #bind-Value="this.Model.Student.Subject" />
<ValidationMessage For="#(() => this.Model.Student.Subject)" />
</div>
</div>
<div class="row">
<div class="col-md-6 text-right">
<button type="button" class="btn btn-success" #onclick="Validate">Validate</button>
</div>
<div class="col-md-6 text-right">
<button type="submit" class="btn btn-success">Submit</button>
</div>
</div>
#code
{
private MyModel Model { get; set; } = new MyModel();
private EditForm myEditForm { get; set; }
private void Validate()
{
this.myEditForm.EditContext.NotifyFieldChanged(
this.myEditForm.EditContext.Field(nameof(this.Model.Student.Name)));
}
private void Submit()
{
}
}
I want validate a single field on Validate button click. If my model is non complex type, above code works fine. Since this is a complex type, above code is not working. Is i am doing any thing wrong here?
Your problem is that you are trying to valid a field that you've never updated. On single field validation it looks like ObjectGraphDataAnnotationsValidator is checking if the field has an entry in the Fields collection in EditContext to verify that some form of modification has taken place i.e. trying to be clever!. If you want what your trying to do then consider using a different validator. There are far better ones out there. Search Blazor Validation Controls. Amongst others there one by Blazorize and one I've written (and you can get the source code for and modify).

Unable to submit Blazor form with sub-entity

I have the following 2 entities (omitting irrelevant fields for brevity) :
public class FacilityModel
{
public long Id { get; set; }
[Required]
public InterfaceModel Interface { get; set; }
}
public class InterfaceModel
{
public long Id { get; set; }
[Required]
[StringLength(50, ErrorMessage = "Name must be shorter than 50 characters")]
public string Name { get; set; }
}
I have the following EditForm in my AddFacility page :
<EditForm Model="#_facility" OnValidSubmit="#(e => {if (_edit) UpdateFacility(); else InsertFacility(); })">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="row">
<div class="col-md-8">
<div class="form-group">
<label for="intfc" class="control-label">Interface</label>
<InputSelect id="intfc" class="form-control" #bind-Value="_facility.Interface">
#foreach (var intfc in Task.Run(() => _interfaceClient.GetAllInterfaces()).Result)
{
<option value="#intfc">#intfc.Name</option>
}
</InputSelect>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-group">
<input type="submit" class="btn btn-primary" value="Save" />
<input type="button" class="btn btn-primary" #onclick="#Cancel" value="Cancel" />
</div>
</div>
</div>
The page correctly displays a list of interfaces in the respective dropdown but, no matter what I do, the form validation comes up with a "The Interface field is required." message. What am I doing wrong, why is the value of the selected interface not saved correctly in the backing model object?
EDIT
I moved the code that pulls the list of interfaces to the OnInitializedAsync() method, thanks for the tip! I tried changing the key to #intfc.Id in my InputSelect field to no avail. I don't see how I can wire anything into the validation process since my understanding is that it happens client-side directly in the form? I'll be happy to be wrong here, can I write server-side code to handle DataAnnotationsValidator myself?

On submit form with partial to controller not back value from partial

I have form in Razor Page look like this (i remove not needed data):
<form class="form-group form-popup"
asp-action="Update"
data-ajax="true"
data-ajax-method="POST">
#Html.AntiForgeryToken()
<div class="popup-section">
<partial name="_FailureCostPartial" model="#Model.CostEdit" />
</div>
<div class="text-center mt-5 popup-edit-buttons">
<button class="btn form-success" type="button" onclick="SubmitForm('#popupId')">
#Localizer.GetLocalizedString("button_Save")<i class="fas fa-check fa-fw"></i>
</button>
<button class="btn form-danger" type="button" data-dismiss="modal">
#Localizer.GetLocalizedString("button_Cancel")<i class="fas fa-times fa-fw"></i>
</button>
</div>
</form>
Model put to this view look like this:
public class FailureEditViewModel
{
public FailureForEditDto Failure { get; set; }
public FailureCostEditViewModel CostEdit { get; set; }
public List<SelectListItem> Suppliers { get; set; }
}
And _FailureCostPartial Look like this:
<div class="details-form-field">
<label class="col-sm-5">#Localizer.GetLocalizedString(nameof(Model.WorkingHoursCost))</label>
<div class="input-container col-sm-7">
<input id="Failure_WorkingHoursCost" asp-for="WorkingHoursCost" value="#Model.WorkingHoursCost" />
<span asp-validation-for="WorkingHoursCost" class="text-danger"></span>
</div>
</div>
<div class="details-form-field">
<label class="col-sm-5">#Localizer.GetLocalizedString(nameof(Model.GeneralCost))</label>
<div class="input-container col-sm-7">
<input id="Failure_GeneralCost" asp-for="GeneralCost" value="#Model.GeneralCost" />
<span asp-validation-for="GeneralCost" class="text-danger"></span>
</div>
</div>
<div class="details-form-field">
<label class="col-sm-5">#Localizer.GetLocalizedString(nameof(Model.TravelCost))</label>
<div class="input-container col-sm-7">
<input id="Failure_TravelCost" asp-for="TravelCost" value="#Model.TravelCost" />
<span asp-validation-for="TravelCost" class="text-danger"></span>
</div>
</div>
<div class="details-form-field">
<label class="col-sm-5">#Localizer.GetLocalizedString(nameof(Model.TotalFailureCost))</label>
<div class="input-container col-sm-7">
<input disabled="disabled" id="Failure_TotalFailureCostField" asp-for="TotalFailureCost" value="#Model.TotalFailureCost" />
<span asp-validation-for="TotalFailureCost" class="text-danger"></span>
</div>
</div>
And model for that partial is:
public class FailureCostEditViewModel
{
public decimal WorkingHoursCost { get; set; }
public decimal GeneralCost { get; set; }
public decimal TravelCost { get; set; }
public decimal TotalFailureCost { get; set; }
}
Now when i set value in input on my partial and i try submit form (from first view) to my Update method in controller back :
What should i do to correct back data from partial for field model.CostEdit?
For each property of the complex type, model binding looks through the sources for the name pattern prefix.property_name. If nothing is found, it looks for just property_name without the prefix.For your receive model is a nested model,so you need prefix to distinguish the property.
Try to add the name to the input element like:name="CostEdit.WorkingHoursCost" in your _FailureCostPartial.cshtml.

Dynamic fields on form asp net core

I need some help to solve this issue, I don't know how to figure out.
Basically I want to add dynamic fields on form but I can't bind the values on Submit Post.
The list is always null, don't have values.
Basically I have a view model with some fieds and one list.
My PlanoAcaoViewModel:
public class PlanoAcaoViewModel
{
public int IdPlanoAcao { get; set; }
public int Numero { get; set; }
public DateTime DataEncerramento { get; set; }
public List<PlanoEvidenciaIForm> ListaPlanoEvidenciaIForm { get; set; }
public class PlanoEvidenciaIForm
{
public int IdPlanoAcaoEvidencia { get; set; }
public IFormFile Arquivo { get; set; }
public string Nota { get; set; }
public DateTime Data { get; set; }
}
}
My Controller:
[HttpPost]
public async Task<IActionResult> Create(PlanoAcaoViewModel model)
{
var num = model.Numero; // It's ok, return the right number
var data = model.DataEncerramento ; // It's ok, return the right date
foreach (var it in model.ListaPlanoEvidenciaIForm)
{
// model.ListaPlanoEvidenciaIForm is NULL
}
}
My cshtml:
#model ProjetoGestor.ViewModel.PlanoAcaoViewModel
<form asp-action="Create" enctype="multipart/form-data">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="row">
<div class="col-auto">
<label asp-for="Numero" class="control-label"></label>
<input asp-for="Numero" class="form-control" disabled type="text" />
</div>
<div class="col-auto">
<label asp-for="DataEncerramento" class="control-label"></label>
<input asp-for="DataEncerramento" class="form-control" type="date" placeholder="dd/mm/yyyy" />
<span asp-validation-for="DataEncerramento" class="text-danger"></span>
</div>
</div>
<div class="row">
#* Dynamic Fields *#
<input asp-for="ListaPlanoEvidenciaIForm[0].Arquivo" class="form-control" type="file" />
<input asp-for="ListaPlanoEvidenciaIForm[0].Nota" class="form-control" />
</div>
<div class="row col-auto">
<div class="align-center">
<a class="btn btn-primary btn-xl" asp-action="Index">Voltar</a> |
<input type="submit" value="Criar" class="btn btn-primary btn-success btn-xl" />
</div>
</div>
</form>
And the dynamic fields
I already tried a lot of ways but without success:
// In this way the model.ListaPlanoEvidenciaIForm is NULL
<input asp-for="ListaPlanoEvidenciaIForm[0].Arquivo" class="form-control" type="file" />
<input asp-for="ListaPlanoEvidenciaIForm[0].Nota" class="form-control" />
// In this way don't do it the POST (don't call the method create post)
<input name="ListaPlanoEvidenciaIForm[0].Arquivo" class="form-control" type="file" />
<input name="ListaPlanoEvidenciaIForm[0].Nota" class="form-control" />
// In this way the model.ListaPlanoEvidenciaIForm have length > 0 but all values inside list are null
<input name="ListaPlanoEvidenciaIForm" class="form-control" type="file" />
<input name="ListaPlanoEvidenciaIForm" class="form-control" />
In this way also don't call the method creat post:
From https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-3.1;
When uploading files using model binding and IFormFile, the action
method can accept:
A single IFormFile.
Any of the following collections that represent several files:
IFormFileCollection
IEnumerable<IFormFile>
List<IFormFile>
There's no mention of a list of objects, each containing an IFormFile. So you may be hitting a limitation of MVC binding. I'd suggest you try to split the list of files from the other values;
public List<PlanoEvidenciaIForm> ListaPlanoEvidenciaIForm { get; set; }
public List<IFormFile> Arquivos { get; set; }
public class PlanoEvidenciaIForm
{
public int IdPlanoAcaoEvidencia { get; set; }
public string Nota { get; set; }
public DateTime Data { get; set; }
}
<!-- The IFormFile doesn't use [ ] -->
<input asp-for="Arquivos" class="form-control" type="file" />
<input asp-for="ListaPlanoEvidenciaIForm[0].Nota" class="form-control" />

Categories