Having spent 3 days trying to get this to work, I'm hoping for some help. I've had to change from a single entry form to a multiple entry table. I've got the table working in terms of the fields/date formats/remove row and add a row buttons (with the help of GitHub Copilot). I have 3 issues remaining. This covers one of the issues and will cover the remaining 2 issues via separate posts: I cannot get the 'Create' entries to save when clicking on either of the Create buttons. I've tried changing return View (viewModel) back to return View(docTrailList) but that has no affect.
Here's the DocTrailList model along with the DocTrailListDetail which is used for the table:
using System.ComponentModel.DataAnnotations;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
namespace Research.Models
{
public class DocTrailList
{
[Key]
public int DetailID { get; set; }
[ForeignKey("ProjectID")]
public int? ProjectID { get; set; }
[Display(Name = "Document Type")]
public string? DocumentType { get; set; }
public string? Version { get; set; }
[DataType(DataType.Date)]
public DateTime? Dated { get; set; }
[Display(Name = "Date Received")]
[DataType(DataType.Date)]
public DateTime? DateReceived { get; set; }
[Display(Name = "Is This Current?")]
public string? IsThisCurrent { get; set; }
[ForeignKey("ProjectID")]
public PIF? PIF { get; set; }
}
public class DocTrailListDetail
{
[ForeignKey("ProjectID")]
public int? ProjectID { get; set; }
public List? Docs { get; set; }
[ForeignKey("ProjectID")]
public PIF? PIF { get; set; }
}
}
Here is the Create part of the controller:
// GET: DocTrailLists/Create
[HttpGet]
public IActionResult Create([FromQuery] int projectID)
{
ViewBag.ProjectIDList = new SelectList(_context.PIF!.ToList(), "ProjectID", "ProjectID");
var viewModel = new DocTrailListDetail { Docs = new List { new DocTrailList() } };
ViewBag.dtname = _context.DocType!.ToList();
ViewBag.current = new SelectList(_context.YesNoList!.ToList(), "YesNo", "YesNo");
return View(viewModel);
}
public Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary GetModelState()
{
return ModelState;
}
// POST: DocTrailLists/Create
// To protect from overposting attacks, enable the specific properties you want to bind to.
// For more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Create(DocTrailListDetail viewModel, [Bind("DetailID,ProjectID,DocumentType,Version,Dated,DateReceived,IsThisCurrent")] DocTrailList docTrailList,
string action,
string returnUrl, [FromQuery] int projectID)
{
// Use existing single-item validator to validate each item in the viewModel
var perItemValidator = new DocTrailListsValidator(_context);
var aggregatedFailures = new List();
if (viewModel?.Docs != null && viewModel.Docs.Count > 0)
{
for (int i = 0; i < viewModel.Docs.Count; i++)
{
var item = viewModel.Docs[i] ?? new DocTrailList();
var itemResult = perItemValidator.Validate(item);
foreach (var failure in itemResult.Errors)
{
// Map property name so model binding associates error with the correct list element
var propName = string.IsNullOrEmpty(failure.PropertyName)
? $"Docs[{i}]"
: $"Docs[{i}].{failure.PropertyName}";
aggregatedFailures.Add(new ValidationFailure(propName, failure.ErrorMessage)
{
AttemptedValue = failure.AttemptedValue
});
}
}
}
else if (docTrailList != null)
{
// Fallback to validating the single docTrailList parameter when posted separately
var singleResult = perItemValidator.Validate(docTrailList);
aggregatedFailures.AddRange(singleResult.Errors);
}
var result = new ValidationResult(aggregatedFailures);
// Populate ModelState with validation failures so the view can render errors
if (!result.IsValid)
{
foreach (var failure in result.Errors)
{
ModelState.AddModelError(failure.PropertyName, failure.ErrorMessage);
}
}
// When validation failed, return to Create View with prepared viewModel and select lists
if (!ModelState.IsValid)
{
// If the binder gave us a single DocTrailList via the separate parameter, use it.
// This preserves any posted DetailID instead of forcing a new blank row.
if ((viewModel?.Docs == null || viewModel.Docs.Count == 0) && docTrailList != null)
{
viewModel = viewModel ?? new DocTrailListDetail();
viewModel.Docs = new List { docTrailList };
}
else if (viewModel == null)
{
viewModel = new DocTrailListDetail { Docs = new List { new DocTrailList() } };
}
else if (viewModel.Docs == null || viewModel.Docs.Count == 0)
{
// fall back to a blank row only if nothing was posted
viewModel.Docs = new List { new DocTrailList() };
}
ViewData["ProjectIDList"] = new SelectList(_context.PIF!.ToList(), "ProjectID", "ProjectID");
ViewBag.ProjectIDList = new SelectList(_context.PIF!.ToList(), "ProjectID", "ProjectID");
ViewBag.current = new SelectList(_context.YesNoList!.ToList(), "YesNo", "YesNo");
var dtname = _context.DocType!.ToList();
ViewBag.dtname = dtname;
ViewBag.current = new SelectList(_context.YesNoList!.ToList(), "YesNo", "YesNo");
return View(viewModel);
}
// Build list of items to save (either the posted list or the single item)
List toSaveList = new List();
if (viewModel?.Docs != null && viewModel.Docs.Count > 0)
{
// Add non-null entries (optionally filter out completely empty rows if desired)
toSaveList.AddRange(viewModel.Docs.Where(d => d != null));
}
else if (docTrailList != null)
{
toSaveList.Add(docTrailList);
}
if (toSaveList.Count == 0)
{
// Nothing to save; redirect back to index (or return view). Preserve existing behavior: redirect to Index.
return RedirectToAction(nameof(Index));
}
// Perform DB update after validation is passed
if (action == "SaveAndBack" && !String.IsNullOrEmpty(returnUrl))
{
try
{
_context.AddRange(toSaveList);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
// Attempt to provide similar fallback behavior as before
var idToCheck = docTrailList?.DetailID ?? viewModel?.Docs?.FirstOrDefault()?.DetailID ?? 0;
if (!DocTrailListExists(idToCheck))
{
return NotFound();
}
else
{
throw;
}
}
// Redirect back to PIF details for the ProjectID on the posted viewModel (prefer viewModel.ProjectID)
var redirectProjectId = viewModel?.ProjectID
?? toSaveList.FirstOrDefault(d => d.ProjectID.HasValue)?.ProjectID
?? docTrailList?.ProjectID;
var redirectUrl = Url.Action("Details", "PIFs", new { id = redirectProjectId });
if (string.IsNullOrEmpty(redirectUrl))
{
return NotFound(); // Handle the case where the URL could not be generated
}
return Redirect(redirectUrl);
}
else
{
try
{
_context.AddRange(toSaveList);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
var idToCheck = docTrailList?.DetailID ?? viewModel?.Docs?.FirstOrDefault()?.DetailID ?? 0;
if (!DocTrailListExists(idToCheck))
{
return NotFound();
}
else
{
throw;
}
}
return RedirectToAction(nameof(Index));
}
}
..and the Create.cshtml create button part:
@* Hidden field named 'action' keeps server-side expectation while avoiding an element named 'action' that can shadow form.action in the DOM. *@
Back to List