Propagate the ModelState errors on dynamic forms

This commit is contained in:
nicolas.dorier 2022-11-25 18:14:33 +09:00
parent 5ff1a59a99
commit 31b25ca169
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
19 changed files with 206 additions and 155 deletions

View file

@ -10,32 +10,55 @@ namespace BTCPayServer.Abstractions.Form;
public class Field
{
public static Field Create(string label, string name, string value, bool required, string helpText, string type = "text")
{
return new Field()
{
Label = label,
Name = name,
Value = value,
OriginalValue = value,
Required = required,
HelpText = helpText,
Type = type
};
}
// The name of the HTML5 node. Should be used as the key for the posted data.
public string Name;
public bool Hidden;
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
public string Type;
public static Field CreateFieldset()
{
return new Field() { Type = "fieldset" };
}
// The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors.
// If this is the first the user sees the form, then value and original value are the same. Value changes as the user starts interacting with the form.
public string Value;
public bool Required;
// The translated label of the field.
public string Label;
// The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor.
public string OriginalValue;
// A useful note shown below the field or via a tooltip / info icon. Should be translated for the user.
public string HelpText;
[JsonExtensionData] public IDictionary<string, JToken> AdditionalData { get; set; }
public List<Field> Fields { get; set; } = new();
public virtual void Validate(ModelStateDictionary modelState)
{
if (Required && string.IsNullOrEmpty(Value))
{
modelState.AddModelError(Name, "This field is required");
}
}
// The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>();
public bool IsValid()
public virtual bool IsValid()
{
ModelStateDictionary modelState = new ModelStateDictionary();
Validate(modelState);
return modelState.IsValid;
return ValidationErrors.Count == 0 && Fields.All(field => field.IsValid());
}
}

View file

@ -1,12 +0,0 @@
namespace BTCPayServer.Abstractions.Form;
public class Fieldset : Field
{
public bool Hidden { get; set; }
public string Label { get; set; }
public Fieldset()
{
Type = "fieldset";
}
}

View file

@ -29,7 +29,7 @@ public class Form
// Are all the fields valid in the form?
public bool IsValid()
{
return Validate(null);
return Fields.Select(f => f.IsValid()).All(o => o);
}
public Field GetFieldByName(string name)
@ -65,16 +65,6 @@ public class Form
return null;
}
#nullable enable
public bool Validate(ModelStateDictionary? modelState)
{
modelState ??= new ModelStateDictionary();
foreach (var field in Fields)
field.Validate(modelState);
return modelState.IsValid;
}
#nullable restore
public List<string> GetAllNames()
{
return GetAllNames(Fields);

View file

@ -1,27 +0,0 @@
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace BTCPayServer.Abstractions.Form;
public class HtmlInputField : Field
{
// The translated label of the field.
public string Label;
// The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor.
public string OriginalValue;
// A useful note shown below the field or via a tooltip / info icon. Should be translated for the user.
public string HelpText;
public HtmlInputField(string label, string name, string value, bool required, string helpText, string type = "text")
{
Label = label;
Name = name;
Value = value;
OriginalValue = value;
Required = required;
HelpText = helpText;
Type = type;
}
// TODO JSON parsing from string to objects again probably won't work out of the box because of the different field types.
}

View file

@ -10,6 +10,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Forms;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest;
@ -41,6 +42,8 @@ namespace BTCPayServer.Controllers
private readonly InvoiceRepository _InvoiceRepository;
private readonly StoreRepository _storeRepository;
public FormComponentProviders FormProviders { get; }
public UIPaymentRequestController(
UIInvoiceController invoiceController,
UserManager<ApplicationUser> userManager,
@ -49,7 +52,8 @@ namespace BTCPayServer.Controllers
EventAggregator eventAggregator,
CurrencyNameTable currencies,
StoreRepository storeRepository,
InvoiceRepository invoiceRepository)
InvoiceRepository invoiceRepository,
FormComponentProviders formProviders)
{
_InvoiceController = invoiceController;
_UserManager = userManager;
@ -59,6 +63,7 @@ namespace BTCPayServer.Controllers
_Currencies = currencies;
_storeRepository = storeRepository;
_InvoiceRepository = invoiceRepository;
FormProviders = formProviders;
}
[BitpayAPIConstraint(false)]
@ -204,7 +209,7 @@ namespace BTCPayServer.Controllers
// POST case: Handle form submit
var formData = Form.Parse(Forms.UIFormsController.GetFormData(prFormId).Config);
formData.ApplyValuesFromForm(this.Request.Form);
if (formData.IsValid())
if (FormProviders.Validate(formData, ModelState))
{
prBlob.FormResponse = JObject.FromObject(formData.GetValues());
result.SetBlob(prBlob);

View file

@ -1,20 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public class FormComponentProvider : IFormComponentProvider
{
private readonly IEnumerable<IFormComponentProvider> _formComponentProviders;
public FormComponentProvider(IEnumerable<IFormComponentProvider> formComponentProviders)
{
_formComponentProviders = formComponentProviders;
}
public string CanHandle(Field field)
{
return _formComponentProviders.Select(formComponentProvider => formComponentProvider.CanHandle(field)).FirstOrDefault(result => !string.IsNullOrEmpty(result));
}
}

View file

@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Form;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace BTCPayServer.Forms;
public class FormComponentProviders
{
private readonly IEnumerable<IFormComponentProvider> _formComponentProviders;
public Dictionary<string, IFormComponentProvider> TypeToComponentProvider = new Dictionary<string, IFormComponentProvider>();
public FormComponentProviders(IEnumerable<IFormComponentProvider> formComponentProviders)
{
_formComponentProviders = formComponentProviders;
foreach (var prov in _formComponentProviders)
prov.Register(TypeToComponentProvider);
}
public bool Validate(Form form, ModelStateDictionary modelState)
{
foreach (var field in form.Fields)
{
if (TypeToComponentProvider.TryGetValue(field.Type, out var provider))
{
provider.Validate(form, field);
foreach (var err in field.ValidationErrors)
modelState.TryAddModelError(field.Name, err);
}
}
return modelState.IsValid;
}
}

View file

@ -10,7 +10,7 @@ public static class FormDataExtensions
public static void AddForms(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<FormDataService>();
serviceCollection.AddSingleton<FormComponentProvider>();
serviceCollection.AddSingleton<FormComponentProviders>();
serviceCollection.AddSingleton<IFormComponentProvider, HtmlInputFormProvider>();
serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
}

View file

@ -15,21 +15,21 @@ public class FormDataService
public static readonly Form StaticFormEmail = new()
{
Fields = new List<Field>() {new HtmlInputField("Enter your email", "buyerEmail", null, true, null)}
Fields = new List<Field>() {Field.Create("Enter your email", "buyerEmail", null, true, null, "email")}
};
public static readonly Form StaticFormAddress = new()
{
Fields = new List<Field>()
{
new HtmlInputField("Enter your email", "buyerEmail", null, true, null, "email"),
new HtmlInputField("Name", "buyerName", null, true, null),
new HtmlInputField("Address Line 1", "buyerAddress1", null, true, null),
new HtmlInputField("Address Line 2", "buyerAddress2", null, false, null),
new HtmlInputField("City", "buyerCity", null, true, null),
new HtmlInputField("Postcode", "buyerZip", null, false, null),
new HtmlInputField("State", "buyerState", null, false, null),
new HtmlInputField("Country", "buyerCountry", null, true, null)
Field.Create("Enter your email", "buyerEmail", null, true, null, "email"),
Field.Create("Name", "buyerName", null, true, null),
Field.Create("Address Line 1", "buyerAddress1", null, true, null),
Field.Create("Address Line 2", "buyerAddress2", null, false, null),
Field.Create("City", "buyerCity", null, true, null),
Field.Create("Postcode", "buyerZip", null, false, null),
Field.Create("State", "buyerState", null, false, null),
Field.Create("Country", "buyerCountry", null, true, null)
}
};
}

View file

@ -1,12 +1,23 @@
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public class HtmlFieldsetFormProvider: IFormComponentProvider
{
public string CanHandle(Field field)
public string View => "Forms/FieldSetElement";
public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
{
return new[] { "fieldset"}.Contains(field.Type) ? "Forms/FieldSetElement" : null;
typeToComponentProvider.Add("fieldset", this);
}
}
public void Validate(Field field)
{
}
public void Validate(Form form, Field field)
{
}
}

View file

@ -1,13 +1,16 @@
using System.Linq;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Validation;
namespace BTCPayServer.Forms;
public class HtmlInputFormProvider: IFormComponentProvider
public class HtmlInputFormProvider: FormComponentProviderBase
{
public string CanHandle(Field field)
public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
{
return new[] {
foreach (var t in new[] {
"text",
"radio",
"checkbox",
@ -29,6 +32,20 @@ public class HtmlInputFormProvider: IFormComponentProvider
"search",
"url",
"tel",
"reset"}.Contains(field.Type) ? "Forms/InputElement" : null;
"reset"})
typeToComponentProvider.Add(t, this);
}
}
public override string View => "Forms/InputElement";
public override void Validate(Form form, Field field)
{
if (field.Required)
{
ValidateField<RequiredAttribute>(field);
}
if (field.Type == "email")
{
ValidateField<MailboxAddressAttribute>(field);
}
}
}

View file

@ -1,8 +1,26 @@
using BTCPayServer.Abstractions.Form;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public interface IFormComponentProvider
{
public string CanHandle(Field field);
string View { get; }
void Validate(Form form, Field field);
void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider);
}
public abstract class FormComponentProviderBase : IFormComponentProvider
{
public abstract string View { get; }
public abstract void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider);
public abstract void Validate(Form form, Field field);
public void ValidateField<T>(Field field) where T : ValidationAttribute, new()
{
var result = new T().GetValidationResult(field.Value, new ValidationContext(field) { DisplayName = field.Label, MemberName = field.Name });
if (result != null)
field.ValidationErrors.Add(result.ErrorMessage);
}
}

View file

@ -26,6 +26,12 @@ namespace BTCPayServer.Forms;
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIFormsController : Controller
{
public FormComponentProviders FormProviders { get; }
public UIFormsController(FormComponentProviders formProviders)
{
FormProviders = formProviders;
}
[AllowAnonymous]
[HttpGet("~/forms/{formId}")]
[HttpPost("~/forms")]
@ -39,24 +45,32 @@ public class UIFormsController : Controller
: Redirect(redirectUrl);
}
return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl });
return GetFormView(formData, redirectUrl);
}
ViewResult GetFormView(FormData formData, string? redirectUrl)
{
return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl });
}
[AllowAnonymous]
[HttpPost("~/forms/{formId}")]
public IActionResult SubmitForm(
string formId,
string? redirectUrl,
string? command,
[FromServices] StoreRepository storeRepository,
[FromServices] UIInvoiceController invoiceController)
{
var formData = GetFormData(formId);
if (formData?.Config is null)
return NotFound();
if (command is not "Submit")
return GetFormView(formData, redirectUrl);
var conf = Form.Parse(formData.Config);
conf.ApplyValuesFromForm(Request.Form);
if (!conf.Validate(ModelState))
return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl });
if (!FormProviders.Validate(conf, ModelState))
return GetFormView(formData, redirectUrl);
var form = new MultiValueDictionary<string, string>();
foreach (var kv in Request.Form)

View file

@ -15,6 +15,7 @@ using BTCPayServer.Client;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Forms;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Plugins.PointOfSale.Models;
@ -40,19 +41,23 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
AppService appService,
CurrencyNameTable currencies,
StoreRepository storeRepository,
UIInvoiceController invoiceController)
UIInvoiceController invoiceController,
FormComponentProviders formProviders)
{
_currencies = currencies;
_appService = appService;
_storeRepository = storeRepository;
_invoiceController = invoiceController;
FormProviders = formProviders;
}
private readonly CurrencyNameTable _currencies;
private readonly StoreRepository _storeRepository;
private readonly AppService _appService;
private readonly UIInvoiceController _invoiceController;
public FormComponentProviders FormProviders { get; }
[HttpGet("/")]
[HttpGet("/apps/{appId}/pos/{viewType?}")]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.AllowAll)]
@ -232,7 +237,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var formData = Form.Parse(Forms.UIFormsController.GetFormData(posFormId).Config);
formData.ApplyValuesFromForm(this.Request.Form);
if (formData.IsValid())
if (FormProviders.Validate(formData, ModelState))
{
formResponse = JObject.FromObject(formData.GetValues());
break;

View file

@ -1,27 +1,19 @@
@using BTCPayServer.Abstractions.Form
@using BTCPayServer.Abstractions.Form
@using BTCPayServer.Forms
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@inject FormComponentProvider FormComponentProvider
@inject FormComponentProviders FormComponentProviders
@model BTCPayServer.Abstractions.Form.Field
@{
if (Model is not Fieldset fieldset)
{
fieldset = JObject.FromObject(Model).ToObject<Fieldset>();
}
}
@if (!fieldset.Hidden)
@if (!Model.Hidden)
{
<fieldset>
<legend class="h3 mt-4 mb-3">@fieldset.Label</legend>
@foreach (var field in fieldset.Fields)
<legend class="h3 mt-4 mb-3">@Model.Label</legend>
@foreach (var field in Model.Fields)
{
var partial = FormComponentProvider.CanHandle(field);
if (string.IsNullOrEmpty(partial))
{
continue;
}
<partial name="@partial" for="@field"></partial>
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial))
{
<partial name="@partial.View" for="@field"></partial>
}
}
</fieldset>
}

View file

@ -1,31 +1,34 @@
@using BTCPayServer.Abstractions.Form
@using BTCPayServer.Abstractions.Form
@using Newtonsoft.Json.Linq
@model BTCPayServer.Abstractions.Form.Field
@{
if (Model is not HtmlInputField field)
{
field = JObject.FromObject(Model).ToObject<HtmlInputField>();
}
var isInvalid = this.ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
var error = isInvalid ? this.ViewContext.ModelState[Model.Name].Errors[0].ErrorMessage : null;
}
<div class="form-group">
@if (field.Required)
@if (Model.Required)
{
<label class="form-label" for="@field.Name" data-required>
@field.Label
<label class="form-label" for="@Model.Name" data-required>
@Model.Label
</label>
}
else
{
<label class="form-label" for="@field.Name">
@field.Label
<label class="form-label" for="@Model.Name">
@Model.Label
</label>
}
<input class="form-control @(field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="@field.Type" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="@("HelpText" + field.Name)"/>
@if (!string.IsNullOrEmpty(field.HelpText))
<input class="form-control @(Model.IsValid() ? "" : "is-invalid")" id="@Model.Name" type="@Model.Type" required="@Model.Required" name="@Model.Name" value="@Model.Value" aria-describedby="@("HelpText" + Model.Name)"/>
@if(isInvalid)
{
<span class="text-danger">@error</span>
}
@if (!string.IsNullOrEmpty(Model.HelpText))
{
<small id="@("HelpText" + field.Name)" class="form-text text-muted">
@field.HelpText
<small id="@("HelpText" + Model.Name)" class="form-text text-muted">
@Model.HelpText
</small>
}

View file

@ -1,14 +1,12 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Forms
@model BTCPayServer.Abstractions.Form.Form
@inject FormComponentProvider FormComponentProvider
@inject FormComponentProviders FormComponentProviders
@foreach (var field in Model.Fields)
{
var partial = FormComponentProvider.CanHandle(field);
if (string.IsNullOrEmpty(partial))
{
continue;
}
<partial name="@partial" for="@field"></partial>
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial))
{
<partial name="@partial.View" for="@field"></partial>
}
}

View file

@ -33,7 +33,7 @@
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
}
<partial name="_Form" model="@Model.Form"/>
<input type="submit" class="btn btn-primary" value="Submit"/>
<input type="submit" class="btn btn-primary" name="command" value="Submit"/>
</form>
</div>
</div>

View file

@ -34,16 +34,16 @@ public class FakeCustodian : ICustodian
var fakeConfig = ParseConfig(config);
var form = new Form();
var fieldset = new Fieldset();
var fieldset = Field.CreateFieldset();
// Maybe a decimal type field would be better?
var fakeBTCBalance = new HtmlInputField("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
var fakeBTCBalance = Field.Create("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
"Enter the amount of BTC you want to have.");
var fakeLTCBalance = new HtmlInputField("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
var fakeLTCBalance = Field.Create("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
"Enter the amount of LTC you want to have.");
var fakeEURBalance = new HtmlInputField("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
var fakeEURBalance = Field.Create("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
"Enter the amount of EUR you want to have.");
var fakeUSDBalance = new HtmlInputField("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
var fakeUSDBalance = Field.Create("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
"Enter the amount of USD you want to have.");
fieldset.Label = "Your fake balances";