DateOnly field not being populated from form .NET6 EF6 Razor Pages No MVC - entity-framework-6

For some reason the dates that I enter in the date-type inputs of the form are not getting into the database. Instead, after all the different methods I researched and tried, to no avail, the dates default to 01/01/01, which I understand to be the defualt min value yes? And in the postgres database, the date fields show "-infinity". I can use pgadmin query tool to update the dates successfully, but when I try to create or edit a record from the form, the above occurs.
When I get to the ModelState.IsValid line in the debugger, it is showing the data I entered correctly in the other fields, but the date fields are showing 01/01/01.
I have been through the docs and other forum posts but none of the fixes I attempted worked.
Any help would be greatly appreciated.
Here is the model
public class ToDo
{
[Key]
public int Id { get; set; }
public DateOnly CreateDate { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string Description { get; set; }
public DateOnly DueDate { get; set; }
public bool Complete { get; set; }
}
... the post
public async Task<IActionResult> OnPost()
{
if (!ModelState.IsValid)
{
var errors = ModelState.SelectMany(x => x.Value.Errors.Select(z => z.Exception));
}
if (ModelState.IsValid)
{
await _db.Todo.AddAsync(Todo);
await _db.SaveChangesAsync();
TempData["success"] = "ToDo created successfully.";
return RedirectToPage("Index");
}
return Page();
}
... and the form
<form method="post">
<input hidden asp-for="Todo.Id" />
<div class="p-3 mt-4">
<div class="row pb-2">
<h2 class="text-primary pl-3">Create ToDo</h2>
<hr />
</div>
<div asp-validation-summary="All"></div>
<div class="mb-3">
<label asp-for="#Model.Todo.CreateDate"></label>
<input asp-for="#Model.Todo.CreateDate" class="form-control" type="date"/>
<span asp-validation-for="Todo.CreateDate" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="#Model.Todo.Name"></label>
<input asp-for="#Model.Todo.Name" class="form-control" />
<span asp-validation-for="Todo.Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="#Model.Todo.Description"></label>
<input asp-for="#Model.Todo.Description" class="form-control" />
<span asp-validation-for="Todo.Description" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="#Model.Todo.DueDate"></label>
<input asp-for="#Model.Todo.DueDate" class="form-control" type="date"/>
<span asp-validation-for="Todo.DueDate" class="text-danger"></span>
</div>
<div class="form-check m-4">
<input asp-for="#Model.Todo.Complete" class="form-check-input" type="checkbox"/>
<label class="form-check-label ms-3" asp-for="#Model.Todo.Complete">
Complete
</label>
</div>
<button type="submit" class="btn btn-outline-primary rounded-pill" style="width:150px;">Update</button>
<a asp-page="Index" class="btn btn-outline-secondary rounded-pill text-white" style="width:150px;">Back To List</a>
</div>
</form>

Thanks Mike that did the trick, here's what I did.
I applied your first solution,
[DataType(DataType.Date)]
public DateTime CreateDate { get; set;}
but I got this error:
"Cannot write DateTime with Kind=Local to PostgreSQL type 'timestamp
with time zone', only UTC is supported"
So I applied the following fix from this thread:
.NET6 and DateTime problem. Cannot write DateTime with Kind=UTC to PostgreSQL type 'timestamp without time zone'
Here's what I did.
I created an "Extensions" folder in my project and within that I created a "UtcDateAnnotation.cs" file and pasted the following.
namespace ToDoRazorNoMvcPostgres.Extensions
{
public static class UtcDateAnnotation
{
private const string IsUtcAnnotation = "IsUtc";
private static readonly ValueConverter<DateTime, DateTime> UtcConverter = new ValueConverter<DateTime, DateTime>(convertTo => DateTime.SpecifyKind(convertTo, DateTimeKind.Utc), convertFrom => convertFrom);
public static PropertyBuilder<TProperty> IsUtc<TProperty>(this PropertyBuilder<TProperty> builder, bool isUtc = true) => builder.HasAnnotation(IsUtcAnnotation, isUtc);
public static bool IsUtc(this IMutableProperty property)
{
if (property != null && property.PropertyInfo != null)
{
var attribute = property.PropertyInfo.GetCustomAttribute<IsUtcAttribute>();
if (attribute is not null && attribute.IsUtc)
{
return true;
}
return ((bool?)property.FindAnnotation(IsUtcAnnotation)?.Value) ?? true;
}
return true;
}
/// <summary>
/// Make sure this is called after configuring all your entities.
/// </summary>
public static void ApplyUtcDateTimeConverter(this ModelBuilder builder)
{
foreach (var entityType in builder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (!property.IsUtc())
{
continue;
}
if (property.ClrType == typeof(DateTime) ||
property.ClrType == typeof(DateTime?))
{
property.SetValueConverter(UtcConverter);
}
}
}
}
}
public class IsUtcAttribute : Attribute
{
public IsUtcAttribute(bool isUtc = true) => this.IsUtc = isUtc;
public bool IsUtc { get; }
}
}
I corrected all the errors by adding the appropriate using statements etc.
Then I added the following in my DbContext file right after the public DbSet statement
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyUtcDateTimeConverter();//Put before seed data and after model creation
}
That did the trick, but it still showed the time as well, I just wanted to display the date.
I tried the below but it didn't work.
[DataType(DataType.Date)]`
[DisplayFormat(DataFormatString = "{0:dd/MM/yyyy}", ApplyFormatInEditMode = true)]
public DateTime CreateDate { get; set; }
Instead I added this to the view:
<td>#Convert.ToString(string.Format("{0:MM/dd/yyyy}", obj.CreateDate))</td>
That did the trick. Thanks again for the help!

You are using the new DateOnly type. The ASP.NET Core model binder does not support binding to DateOnly at the moment (https://github.com/dotnet/aspnetcore/issues/34591). Until .NET 7, you can use a DateTime type and data annotations to control the input type that gets rendered (https://www.learnrazorpages.com/razor-pages/forms/dates-and-times):
[DataType(DataType.Date)]
public DateTime CreateDate { get; set; }
Alternatively, you can extract the value from Request.Form yourself and assign it to the relevant property:
if(DateTime.TryParse(Request.Form["Todo.CreateDate"].ToString(), out var created))
{
Todo.CreateDate = DateOnly.FromDateTime(created);
}
else
{
ModelState.AddModelError("Todo.CreateDate", "A created date must be a date");
}

Related

EF Core ModelSate Invalid because form is passing foreign key name and value attributes

Very new to MVC Core and C# and just as I think I'm getting the hang of something there's a new curve ball. I have a form which is based on a model which has a foreign key. When I submit the form to the controller the modelState is invalid because the form is passing something back which isn't in the model it is based on. Here is the model:
public partial class Agreement
{
public Agreement()
{
AgreementAmendments = new HashSet<AgreementAmendment>();
Bundles = new HashSet<Bundle>();
Invoices = new HashSet<Invoice>();
}
public int Id { get; set; }
public int OrgId { get; set; }
public string AgreementNumber { get; set; } = null!;
public string? IrespondReference { get; set; }
public string? DocumentLink { get; set; }
public virtual Organization Org { get; set; }
public virtual ICollection<AgreementAmendment> AgreementAmendments { get; set; }
public virtual ICollection<Bundle> Bundles { get; set; }
public virtual ICollection<Invoice> Invoices { get; set; }
}
This is the Get Create Action Method:
public IActionResult Create()
{
ViewData["OrgId"] = new SelectList(_context.Organizations, "Id", "ShortName");
return View();
}
This is the form:
<div class="row">
<div class="col-md-4">
<form asp-action="Create">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="OrgId" class="control-label">Organization</label>
<select asp-for="OrgId" class ="form-control" asp-items="ViewBag.OrgId"></select>
</div>
<div class="form-group">
<label asp-for="AgreementNumber" class="control-label">Agreement Number</label>
<input asp-for="AgreementNumber" class="form-control" />
<span asp-validation-for="AgreementNumber" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="IrespondReference" class="control-label">Internal Reference</label>
<input asp-for="IrespondReference" class="form-control" />
<span asp-validation-for="IrespondReference" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="DocumentLink" class="control-label">Document Link</label>
<input asp-for="DocumentLink" class="form-control" />
<span asp-validation-for="DocumentLink" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-primary" />
</div>
</form>
</div>
</div>
And this is the HttpPost Create Action Method:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("OrgId,AgreementNumber,IrespondReference,DocumentLink")] Agreement agreement)
{
if (ModelState.IsValid)
{
_context.Add(agreement);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
ViewData["OrgId"] = new SelectList(_context.Organizations, "Id", "Id", agreement.OrgId);
return View();
}
When I look at the results of the ModelState it shows an error with the Org Key but as far as I can see the form should just be returning the OrgId as per the model. Can someone please let me know where I am going wrong.
Created a View Model for Agreements to handle the form input and then passed that to the base Agreement Model which seems like unnecessary work. Why can't EF Core handle this stuff without having to constantly build View Models just because there is a foreign key?
Anyway, this is the final HttpPost code for others who run into the same issue:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(AgreementWriteViewModel newagreement)
{
if (ModelState.IsValid)
{
var model = new Agreement
{
OrgId = newagreement.OrgId,
AgreementNumber = newagreement.AgreementNumber,
IrespondReference = newagreement.IrespondReference,
DocumentLink = newagreement.DocumentLink,
};
_context.Add(model);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
ViewData["OrgId"] = new SelectList(_context.Organizations, "Id", "ShortName", newagreement.OrgId);
return View();
}

Asp.net core MVC fetch image from DB based on the Category

I am sorry to bother with simple matters but i really cannot find a way out of this problem.
I am building a Gallery, which container different fields, one of those is Category.
Category Class is a public Enum, and i would like to retrieve all the images in the Database and display them in View based on my Category selection.
Here you can find the code that i wrote so far.
View:
<form method="get" asp-controller="Gallery" asp-action="index">
<div style="height:60px;" class="container">
<div class="row">
<div class="col-md-5">
<div class="row" style="padding-top:10px;">
<div class="col-md-5">
#Html.Editor("Name", new { htmlAttributes = new { #class = "form-control", placeholder = "Name..." } })
</div>
</div>
</div>
<div class="col-md-5">
<select class="custom-select form-control mr-sm-2" asp-items="Html.GetEnumSelectList<Category>()"></select>
</div>
<div class="col-md-1">
<div class="row" style="padding-top:10px; padding-right:20px;">
<button type="submit" name="submit" class="btn btn-success form-control" value="submit">
<i class="fas fa-search fa-1x"></i>
</button>
</div>
</div>
</div>
</div>
</form>
Controller:
public IActionResult Index(string Name, Category category)
{
var model = _galleryRepository.GetAllImages();
StringBuilder param = new StringBuilder();
param.Append("&Name=");
if (Name != null)
{
param.Append(Name);
}
if(Name != null)
{
model = _galleryRepository.SearchName(Name);
}
if(category != Category.All)
{
model = _galleryRepository.SearchCategory(category);
}
return View(model);
}
Model Category:
public enum Category
{
All,
Photography,
Portrait,
Nature
}
Model Gallery:
public class Gallery
{
public int Id { get; set; }
public int Like { get; set; }
public string Comment { get; set; }
[Required]
[MaxLength(40, ErrorMessage ="Name cannot exceed 40 characters")]
public string Name { get; set; }
[Required]
[MaxLength(100, ErrorMessage = "Description cannot exceed 100 characters")]
public string Description { get; set; }
[Required]
public Category Category { get; set; }
public string PhotoPath { get; set; }
}
I did Managed to create a search form based on the Name of the image and it works just fine. But when it come to retrieve the images based on the Category Selection, it does not work.
i used a breakpoint on the Controller on the If statment related to category, and i realized that the condition fires but the model inside no.
So i am asking to the expert for an explanation about how to fix it as it the first time that i work with Enum and retrieving datas based on Enum classes.
Thank you so much for your help and i hope i made clear my problem.
Change your view like below,then you could pass the selected item to category:
#model Gallery
<form method="get" asp-controller="Gallery" asp-action="index">
//...
<div class="col-md-5">
<select asp-for="Category" class="custom-select form-control mr-sm-2" asp-items="Html.GetEnumSelectList<Category>()"></select>
</div>
//...
</form>
The default model binder won't work with Enum types. Either you need to change the parameter to of type string and convert it to it's equivalent enum type before performing the comparisions OR provide your own implementation of model binder and override the default one. If I were you, I will go with the simplest solution like below,
public IActionResult Index(string Name, string selectedCategory)
{
var category = Enum.Parse(typeof(Category),selectedCategory,true);
var model = _galleryRepository.GetAllImages();
StringBuilder param = new StringBuilder();
param.Append("&Name=");
if (Name != null)
{
param.Append(Name);
}
if(Name != null)
{
model = _galleryRepository.SearchName(Name);
}
if(category != Category.All)
{
model = _galleryRepository.SearchCategory(category);
}
return View(model);
}

handling form in controller using viewmodel with two parameters

I want to save values from a form to my database. I'm using a viewmodel with an selectlist property and a regular model. The value from the dropdown doesn't get saved. Despite being a trivial and seemingly pretty simple thing, I'm pretty lost.
Below my code:
Model:
public class Movie
{
public int MovieID { get; set; }
public string Name { get; set; }
public int StudioID { get; set; }
public Studio Studio { get; set; }
}
My ViewModel:
public class CreateMoviesViewModel
{
public Movie Movie { get; set; }
public IEnumerable<SelectListItem> StudiosSelectList { get; set; }
}
My Controller:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateMoviesViewModel movieViewModel)
{
if (ModelState.IsValid)
{
_context.Add(movieViewModel);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
movieViewModel.StudiosSelectList = new SelectList(_context.Studios.AsNoTracking(), "StudioID", "Name");
return View(movieViewModel);
And finally, my Form:
<form asp-action="Create">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Movie.MovieID" />
<div class="form-group">
<label asp-for="Movie.Name" class="control-label"></label>
<input asp-for="Movie.Name" class="form-control" />
<span asp-validation-for="Movie.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Movie.StudioID" class="control-label"></label>
#Html.DropDownListFor(m => m.StudiosSelectList, Model.StudiosSelectList, "Select one")
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-default" />
</div>
</form>
It is probably something wrong with my dropdown list, or with the logic described in the POST section. Any help is greatly appreciated!
You need to pass the selected dropdown value to your model:
public class CreateMoviesViewModel
{
public int SelectedValueId { get; set; } // <-- not sure what are you selecting, this could be MovieId if you are selecting a movie
public Movie Movie { get; set; }
public IEnumerable<SelectListItem> StudiosSelectList { get; set; }
}
Then you can use:
#Html.DropDownListFor(m => m.SelectedValueId, m.StudiosSelectList)
This way, the selected value Id would be passed to your model.
SelectValueId should be initialized to the default value that you want to display in the Dropdown.

How do I get my view to show my database table

i'm new to ASP.net. I am trying to figure out how to get my Edit/Display pages working properly for a multiselect listbox.
My create works fine, and saves to my database, but I cannot figure out how to return to the edit page, and still see the values selected.
Hopes this makes sense.
Here is the code that I have for the create method. The record saves fine in both tables, but I am having trouble getting the values from my Options table.
I want to try to make the Edit view look like the Create View
Controller
[HttpPost]
public IActionResult Create(MusicViewModel model)
{
if(ModelState.IsValid)
{
var album = new Music();
album.Album = model.Album;
album.Artist = model.Artist;
album.Label = model.Label;
album.Review = model.Review;
album.ReleaseDate = model.ReleaseDate;
foreach(Types type in model.Options)
{var opt = new Options();
opt.Music = album;
opt.Types = type;
_musicData.AddOptions(opt);
}
_musicData.Add(album);
_musicData.Commit();
return RedirectToAction("Details", new { id = album.MusicID });
}
return View();
}
Music.cs
public enum Types
{
Spotify,
Groove,
CD,
Vinyl,
Pandora
}
public class Music
{
public int MusicID { get; set; }
[Required]
[MaxLength(50),MinLength(5)]
public string Artist { get; set; }
[Required, MinLength(5)]
public string Album { get; set; }
public int Rating { get; set; }
public Label Label { get; set; }
[DataType(DataType.Date)]
[Display(Name ="Release Date")]
public DateTime ReleaseDate { get; set; }
public string Review { get; set; }
public List<Options> Options { get; set; }
}
public class Options
{
public int OptionsID { get; set; }
public Types Types { get; set; }
public int MusicID { get; set; }
public Music Music { get; set; }
}
public class MusicDbContext:DbContext
{
public DbSet<Music> Albums { get; set; }
public DbSet<Options> Options { get; set; }
}
View
#model Music
....
<form asp-action="Create" method="post">
<div class="row">
<div class="col-md-3 col-md-offset-2">
<fieldset class="form-group">
<label asp-for="Artist"></label>
<input class="form-control" asp-for="Artist" />
<span asp-validation-for="Artist" class="alert"></span>
</fieldset>
</div>
<div class="col-md-3">
<fieldset class="form-group">
<label asp-for="Album"></label>
<input class="form-control" asp-for="Album" />
<span asp-validation-for="Album" class="alert"></span>
</fieldset>
</div>
<div class="col-md-3">
<label asp-for="Label"></label>
#Html.DropDownList("Label", Html.GetEnumSelectList(typeof(Label)), "-------", new { #class = "form-control" })
</div>
</div>
<div class="row">
<div class="col-md-3 col-md-offset-2">
<fieldset class="form-group">
<label asp-for="Options"></label>
<select multiple class="form-control" asp-for="Options"
asp-items="#Html.GetEnumSelectList(typeof(Types))"></select>
</fieldset>
</div>
<div class="col-md-3">
<fieldset class="form-group">
<label asp-for="ReleaseDate"></label>
<input type="text" asp-for="ReleaseDate" class="DateBox form-control" />
<span asp-validation-for="ReleaseDate" class="alert"></span>
</fieldset>
</div>
</div>
<div class="col-md-offset-3"><input class="btn btn-info" type="submit" value="Submit" /></div>
</form>
I figured it out, probably not the most efficient way, but at least the code works
[HttpPost]
public IActionResult Edit(int id,MusicViewModel model)
{
var album = _musicData.GetM(id);
if (album != null && ModelState.IsValid)
{
album.Album = model.Album;
album.Artist = model.Artist;
album.Label = model.Label;
album.Review = model.Review;
album.ReleaseDate = model.ReleaseDate;
_musicData.RemoveOptions(id);
foreach (Types type in model.Options)
{
var opt = new Options();
opt.MusicID = id;
opt.Types = type;
_musicData.AddOptions(opt);
}
_musicData.Commit();
return RedirectToAction("Details",id);
}
return View(album);
}

Custom data annotations not displaying error on postback - ASP.NET core MVC6

I have looked at a number of posts on this topic - custom server side validation - here and here and here (I believe this is for MVC4) but none seem to address this for me.
I have created a basic MVC6 project (two textboxes) just to test dataannotations and still cannot get this to work. I am using, as a basis, this tutorial and have recreated his data validation class with no changes.
I am not using the actual model but rather a viewModel that if validation is successful updates the database through assignment to the model.
I am not interested in a successful validation here but whether, upon a "(ModelState.IsValid)" equal to "False", it displays the error message under the textbox.
I have stepped through it and find that it does step through the actual custom validator and it certainly gives a model state of false on the custom validation - returns the view - but no error message displays.
If, however, I remove everything from the textbox - make it empty - the viewmodel is returned but this time it comesup with the "Required" error in red.. that is, the error messages work for data annotations just not custom annotations.
OK so why does it display normal annotation validation errors and not the custom validation errors?
Is this as a result of ASP.NET core or is it just the way I am returning the viewmodel (eg an error by me much more likely)?
I have decided to include all the moving parts as it might be any one of them not being correct or at issue. So thats a model, a viewmodel based on the model, a controller and the custom validator class as per the tutorial.
public class CompanyDetail
{
public int CompanyDetailId { get; set; }
public string CompanyName { get; set; }
public string ABN { get; set; }
}
A CompanyDetailViewModel with the data annotations added:
public class CompanyDetailsViewModel
{
public int CompanyDetailsId { get; set; }
[ExcludeChar("/")]
[Required(ErrorMessage = "A Company Name is required")]
[Display(Name = "Company Name:")]
[StringLength(100)]
public string CompanyName { get; set; }
[Required(ErrorMessage = "An ABN is required")]
[CheckValidABN(ErrorMessage = "This is not a valid ABN")]
[Display(Name = "ABN:")]
public string ABN { get; set; }
}
A controller:
public class CompanyDetailsController : Controller
{
private ApplicationDbContext _context;
public CompanyDetailsController(ApplicationDbContext context)
{
_context = context;
}
// GET: CompanyDetailsViewModels/Edit/5
public IActionResult Edit()
{
var Company = _context.CompanyDetails.First();
if (Company == null)
{
return HttpNotFound();
}
var CompanyDetails = new CompanyDetailsViewModel();
CompanyDetails.CompanyDetailsId = Company.CompanyDetailId;
CompanyDetails.CompanyName = Company.CompanyName;
CompanyDetails.ABN = Company.ABN;
return View(CompanyDetails);
}
// POST: CompanyDetailsViewModels/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Edit(CompanyDetailsViewModel companyDetailsViewModel)
{
if (ModelState.IsValid)
{
CompanyDetail Company = _context.CompanyDetails.First();
var CompanyDetails = new CompanyDetailsViewModel();
Company.CompanyName = CompanyDetails.CompanyName;
CompanyDetails.ABN = Company.ABN;
_context.CompanyDetails.Update(Company);
_context.SaveChanges();
return RedirectToAction("Index");
}
return View(companyDetailsViewModel);
}
}
A view with a (vanilla scaffolded) form - it uses the viewmodel as the model:
<form asp-action="Edit">
<div class="form-horizontal">
<h4>CompanyDetailsViewModel</h4>
<hr />
<div asp-validation-summary="ValidationSummary.ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="CompanyDetailsId" />
<div class="form-group">
<label asp-for="ABN" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="ABN" class="form-control" />
<span asp-validation-for="ABN" class="text-danger" />
</div>
</div>
<div class="form-group">
<label asp-for="CompanyName" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="CompanyName" class="form-control" />
<span asp-validation-for="CompanyName" class="text-danger" />
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<input type="submit" value="Save" class="btn btn-default" />
</div>
</div>
</div>
and the actual custom validation class:
public class ExcludeChar : ValidationAttribute
{
private readonly string _chars;
public ExcludeChar(string chars)
: base("{0} contains invalid character.")
{
_chars = chars;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value != null)
{
for (int i = 0; i < _chars.Length; i++)
{
var valueAsString = value.ToString();
if (valueAsString.Contains(_chars[i]))
{
var errorMessage = FormatErrorMessage(validationContext.DisplayName);
return new ValidationResult(errorMessage);
}
}
}
return ValidationResult.Success;
}
}
I figured it out in your code you are using
<span asp-validation-for="number" class="text-danger" />
Thats default html generated by Visual Studio (no idea why). You need to add the closing tag. Use it like
<span asp-validation-for="number" class="text-danger" ></span>
and it'll show the error messages right under the field.
What you have to do is specify in the view where you want your custom error message to be shown.
example:
<div>
#Html.ValidationMessage("CreditRating")
</div>
Then returning a ValidationResult that relates to the "member" CreditRating will show in that part of the view. member is in quotes because the name can actually be any name, does not have to be a real name of a real property.
results.Add(new ValidationResult("NO NO NO", new[] { "CreditRating" }));
I agree that this is surprising behavior. I seems like custom errors are handled differently. maybe some naming convention that is not documented.

Resources