You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
653 lines
19 KiB
653 lines
19 KiB
using System.ComponentModel.DataAnnotations;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using Connected.Annotations;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Forms;
|
|
using static System.String;
|
|
|
|
namespace Connected.Components;
|
|
|
|
public abstract class FormComponent<T, U> : UIComponent, IFormComponent, IDisposable
|
|
{
|
|
private Converter<T, U> _converter;
|
|
|
|
/// <summary>
|
|
/// Invoked whenever the string value cannot be converted
|
|
/// </summary>
|
|
public event EventHandler<string> ConversionErrorOccured;
|
|
|
|
protected FormComponent(Converter<T, U> converter)
|
|
{
|
|
_converter = converter ?? throw new ArgumentNullException(nameof(converter));
|
|
_converter.ErrorOccured += (s, e) => OnConversionError(e);
|
|
}
|
|
|
|
[CascadingParameter]
|
|
internal IForm? Form { get; set; }
|
|
|
|
/// <summary>
|
|
/// If true, this is a top-level form component. If false, this input is a sub-component of another input (i.e. TextField, Select, etc).
|
|
/// If it is sub-component, it will NOT do form validation!!
|
|
/// </summary>
|
|
[CascadingParameter(Name = "SubscribeToParentForm")]
|
|
internal bool SubscribeToParentForm { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// If true, this form input is required to be filled out.
|
|
/// </summary>
|
|
[Parameter]
|
|
public bool Required { get; set; }
|
|
|
|
/// <summary>
|
|
/// The error text that will be displayed if the input is not filled out but required.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string RequiredError { get; set; } = "Required";
|
|
|
|
/// <summary>
|
|
/// The ErrorText that will be displayed if <see cref="HasError"/> is set to true.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string ErrorText { get; set; }
|
|
|
|
/// <summary>
|
|
/// If true, the label will be displayed in an error state.
|
|
/// </summary>
|
|
[Parameter]
|
|
public bool HasError { get; set; }
|
|
|
|
/// <summary>
|
|
/// The ErrorId that will be used by aria-describedby if <see cref="HasError"/> is true
|
|
/// </summary>
|
|
[Parameter]
|
|
public string ErrorId { get; set; }
|
|
|
|
/// <summary>
|
|
/// The generic converter of the component.
|
|
/// </summary>
|
|
[Parameter]
|
|
public Converter<T, U> Converter
|
|
{
|
|
get => _converter;
|
|
set => SetConverter(value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The culture of the component. Also sets the culture of the <see cref="Converter"/> .
|
|
/// </summary>
|
|
[Parameter]
|
|
public CultureInfo Culture
|
|
{
|
|
get => _converter.Culture;
|
|
set => SetCulture(value);
|
|
}
|
|
|
|
private string _conversionError { get; set; }
|
|
|
|
protected virtual bool SetConverter(Converter<T, U> value)
|
|
{
|
|
var changed = _converter != value;
|
|
|
|
if (changed)
|
|
{
|
|
/*
|
|
* Converter is mandatory at all times
|
|
*/
|
|
_converter = value ?? throw new ArgumentNullException(nameof(value));
|
|
_converter.ErrorOccured += (s, e) => OnConversionError(e);
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
protected virtual bool SetCulture(CultureInfo value)
|
|
{
|
|
var changed = _converter.Culture != value;
|
|
|
|
if (changed)
|
|
_converter.Culture = value;
|
|
|
|
return changed;
|
|
}
|
|
|
|
private void OnConversionError(string error)
|
|
{
|
|
// note: we need to update the form here because the conversion error might lead to not updating the value
|
|
// ... which leads to not updating the form
|
|
|
|
//TODO Why does the form need to be updated?
|
|
Modified = true;
|
|
|
|
_conversionError = error;
|
|
|
|
Form?.Update(this);
|
|
|
|
ConversionErrorOccured?.Invoke(this, error);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True if the conversion from string to T failed
|
|
/// </summary>
|
|
public bool ConversionError => !string.IsNullOrWhiteSpace(_conversionError);
|
|
|
|
/// <summary>
|
|
/// The error message of the conversion error from string to T. Null otherwise
|
|
/// </summary>
|
|
public string ConversionErrorMessage => _conversionError;
|
|
|
|
/// <summary>
|
|
/// True if the input has any of the following errors: An error set from outside, a conversion error or
|
|
/// one or more validation errors
|
|
/// </summary>
|
|
public bool HasErrors => HasError || ConversionError || ValidationErrors.Count > 0;
|
|
|
|
/// <summary>
|
|
/// Return the validation error text or the conversion error message.
|
|
/// </summary>
|
|
/// <returns>Error text/message</returns>
|
|
public string? GetErrorText()
|
|
{
|
|
// ErrorText is either set from outside or the first validation error
|
|
if (!IsNullOrWhiteSpace(ErrorText))
|
|
return ErrorText;
|
|
|
|
if (!IsNullOrWhiteSpace(ConversionErrorMessage))
|
|
return ConversionErrorMessage;
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// This manages the state of having been modified by the user. A form control always starts out unmodified
|
|
/// but becomes modified when the user performed input or the blur event was raised.
|
|
///
|
|
/// The modified state is only relevant for inputs that have no value (i.e. empty text fields). Being unmodified will
|
|
/// suppress the display of the <see cref="RequiredError"/>
|
|
/// </summary>
|
|
public bool Modified { get; protected set; }
|
|
|
|
#region MudForm Validation
|
|
|
|
public List<string> ValidationErrors { get; set; } = new List<string>();
|
|
|
|
/// <summary>
|
|
/// A validation func or a validation attribute. Supported types are:
|
|
/// <para>Func<T, bool> ... will output the standard error message "Invalid" if false</para>
|
|
/// <para>Func<T, string> ... outputs the result as error message, no error if null </para>
|
|
/// <para>Func<T, IEnumerable< string >> ... outputs all the returned error messages, no error if empty</para>
|
|
/// <para>Func<object, string, IEnumerable< string >> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty</para>
|
|
/// <para>Func<T, Task< bool >> ... will output the standard error message "Invalid" if false</para>
|
|
/// <para>Func<T, Task< string >> ... outputs the result as error message, no error if null</para>
|
|
/// <para>Func<T, Task<IEnumerable< string >>> ... outputs all the returned error messages, no error if empty</para>
|
|
/// <para>Func<object, string, Task<IEnumerable< string >>> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty</para>
|
|
/// <para>System.ComponentModel.DataAnnotations.ValidationAttribute instances</para>
|
|
/// </summary>
|
|
[Parameter]
|
|
[Category(CategoryTypes.FormComponent.Validation)]
|
|
public object Validation { get; set; }
|
|
|
|
private T __value;
|
|
|
|
/// <summary>
|
|
/// This is the form component's value.
|
|
/// </summary>
|
|
protected T _value
|
|
{
|
|
get => __value;
|
|
set
|
|
{
|
|
__value = value;
|
|
_conversionError = null;
|
|
}
|
|
}
|
|
|
|
// These are the fire-and-forget methods to launch an async validation process.
|
|
// After each async step, we make sure the current Value of the component has not changed while
|
|
// async code was executed to avoid race condition which could lead to incorrect validation results.
|
|
protected void BeginValidateAfter(Task task)
|
|
{
|
|
Func<Task> execute = async () =>
|
|
{
|
|
var value = _value;
|
|
|
|
await task;
|
|
|
|
// we validate only if the value hasn't changed while we waited for task.
|
|
// if it has in fact changed, another validate call will follow anyway
|
|
if (EqualityComparer<T>.Default.Equals(value, _value))
|
|
{
|
|
BeginValidate();
|
|
}
|
|
};
|
|
execute().AndForget();
|
|
}
|
|
|
|
protected void BeginValidate()
|
|
{
|
|
Func<Task> execute = async () =>
|
|
{
|
|
var value = _value;
|
|
|
|
await ValidateValue();
|
|
|
|
if (EqualityComparer<T>.Default.Equals(value, _value))
|
|
{
|
|
EditFormValidate();
|
|
}
|
|
};
|
|
execute().AndForget();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cause this component to validate its value.
|
|
/// </summary>
|
|
public Task Validate()
|
|
{
|
|
// when a validation is forced, we must set Touched to true, because for untouched fields with
|
|
// no value, validation does nothing due to the way forms are expected to work (display errors
|
|
// only after fields have been touched).
|
|
Modified = true;
|
|
return ValidateValue();
|
|
}
|
|
|
|
protected virtual async Task ValidateValue()
|
|
{
|
|
var changed = false;
|
|
var errors = new List<string>();
|
|
try
|
|
{
|
|
// conversion error
|
|
if (ConversionError)
|
|
errors.Add(ConversionErrorMessage);
|
|
// validation errors
|
|
if (Validation is ValidationAttribute)
|
|
ValidateWithAttribute(Validation as ValidationAttribute, _value, errors);
|
|
else if (Validation is Func<T, bool>)
|
|
ValidateWithFunc(Validation as Func<T, bool>, _value, errors);
|
|
else if (Validation is Func<T, string>)
|
|
ValidateWithFunc(Validation as Func<T, string>, _value, errors);
|
|
else if (Validation is Func<T, IEnumerable<string>>)
|
|
ValidateWithFunc(Validation as Func<T, IEnumerable<string>>, _value, errors);
|
|
else if (Validation is Func<object, string, IEnumerable<string>>)
|
|
ValidateModelWithFullPathOfMember(Validation as Func<object, string, IEnumerable<string>>, errors);
|
|
else
|
|
{
|
|
var value = _value;
|
|
|
|
if (Validation is Func<T, Task<bool>>)
|
|
await ValidateWithFunc(Validation as Func<T, Task<bool>>, _value, errors);
|
|
else if (Validation is Func<T, Task<string>>)
|
|
await ValidateWithFunc(Validation as Func<T, Task<string>>, _value, errors);
|
|
else if (Validation is Func<T, Task<IEnumerable<string>>>)
|
|
await ValidateWithFunc(Validation as Func<T, Task<IEnumerable<string>>>, _value, errors);
|
|
else if (Validation is Func<object, string, Task<IEnumerable<string>>>)
|
|
await ValidateModelWithFullPathOfMember(Validation as Func<object, string, Task<IEnumerable<string>>>, errors);
|
|
|
|
changed = !EqualityComparer<T>.Default.Equals(value, _value);
|
|
}
|
|
|
|
// Run each validation attributes of the property targeted with `For`
|
|
if (_validationAttrsFor != null)
|
|
{
|
|
foreach (var attr in _validationAttrsFor)
|
|
{
|
|
ValidateWithAttribute(attr, _value, errors);
|
|
}
|
|
}
|
|
|
|
// required error (must be last, because it is least important!)
|
|
if (Required)
|
|
{
|
|
if (Modified && !HasValue(_value))
|
|
errors.Add(RequiredError);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// If Value has changed while we were validating it, ignore results and exit
|
|
if (!changed)
|
|
{
|
|
// this must be called in any case, because even if Validation is null the user might have set Error and ErrorText manually
|
|
// if Error and ErrorText are set by the user, setting them here will have no effect.
|
|
// if Error, create an error id that can be used by aria-describedby on input control
|
|
ValidationErrors = errors;
|
|
HasError = errors.Count > 0;
|
|
ErrorText = errors.FirstOrDefault();
|
|
ErrorId = HasErrors ? Guid.NewGuid().ToString() : null;
|
|
Form?.Update(this);
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
protected virtual bool HasValue(T value)
|
|
{
|
|
if (typeof(T) == typeof(string))
|
|
return !IsNullOrWhiteSpace(value as string);
|
|
|
|
return value != null;
|
|
}
|
|
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "In the context of EditContext.Model / FieldIdentifier.Model they won't get trimmed.")]
|
|
protected virtual void ValidateWithAttribute(ValidationAttribute attr, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
// The validation context is applied either on the `EditContext.Model`, '_fieldIdentifier.Model', or `this` as a stub subject.
|
|
// Complex validation with fields references (like `CompareAttribute`) should use an EditContext or For when not using EditContext.
|
|
var validationContextSubject = EditContext?.Model ?? _fieldIdentifier.Model ?? this;
|
|
var validationContext = new ValidationContext(validationContextSubject);
|
|
if (validationContext.MemberName is null && _fieldIdentifier.FieldName is not null)
|
|
validationContext.MemberName = _fieldIdentifier.FieldName;
|
|
var validationResult = attr.GetValidationResult(value, validationContext);
|
|
if (validationResult != ValidationResult.Success)
|
|
errors.Add(validationResult.ErrorMessage);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// Maybe conditionally add full error message if `IWebAssemblyHostEnvironment.IsDevelopment()`
|
|
// Or log using proper logger.
|
|
errors.Add($"An unhandled exception occurred: {e.Message}");
|
|
}
|
|
}
|
|
|
|
protected virtual void ValidateWithFunc(Func<T, bool> func, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
if (!func(value))
|
|
errors.Add("Invalid");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual void ValidateWithFunc(Func<T, string> func, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
var error = func(value);
|
|
if (error != null)
|
|
errors.Add(error);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual void ValidateWithFunc(Func<T, IEnumerable<string>> func, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
foreach (var error in func(value))
|
|
errors.Add(error);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual void ValidateModelWithFullPathOfMember(Func<object, string, IEnumerable<string>> func, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
if (Form?.Model == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (For == null)
|
|
{
|
|
errors.Add($"For is null, please set parameter For on the form input component of type {GetType().Name}");
|
|
return;
|
|
}
|
|
|
|
foreach (var error in func(Form.Model, For.GetFullPathOfMember()))
|
|
errors.Add(error);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual async Task ValidateWithFunc(Func<T, Task<bool>> func, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
if (!await func(value))
|
|
errors.Add("Invalid");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual async Task ValidateWithFunc(Func<T, Task<string>> func, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
var error = await func(value);
|
|
if (error != null)
|
|
errors.Add(error);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual async Task ValidateWithFunc(Func<T, Task<IEnumerable<string>>> func, T value, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
foreach (var error in await func(value))
|
|
errors.Add(error);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
protected virtual async Task ValidateModelWithFullPathOfMember(Func<object, string, Task<IEnumerable<string>>> func, List<string> errors)
|
|
{
|
|
try
|
|
{
|
|
if (Form?.Model == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (For == null)
|
|
{
|
|
errors.Add($"For is null, please set parameter For on the form input component of type {GetType().Name}");
|
|
return;
|
|
}
|
|
|
|
foreach (var error in await func(Form.Model, For.GetFullPathOfMember()))
|
|
errors.Add(error);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
errors.Add("Error in validation func: " + e.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify the Form that a field has changed if SubscribeToParentForm is true
|
|
/// </summary>
|
|
protected void FieldChanged(object newValue)
|
|
{
|
|
if (SubscribeToParentForm)
|
|
Form?.FieldChanged(this, newValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset the value and the validation.
|
|
/// </summary>
|
|
public void Reset()
|
|
{
|
|
ResetValue();
|
|
ResetValidation();
|
|
}
|
|
|
|
protected virtual void ResetValue()
|
|
{
|
|
/* to be overridden */
|
|
_value = default;
|
|
_conversionError = null;
|
|
Modified = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset the validation.
|
|
/// </summary>
|
|
public void ResetValidation()
|
|
{
|
|
HasError = false;
|
|
ValidationErrors.Clear();
|
|
ErrorText = null;
|
|
StateHasChanged();
|
|
}
|
|
|
|
#endregion
|
|
|
|
|
|
#region --> Blazor EditForm validation support
|
|
|
|
/// <summary>
|
|
/// This is the form validation context for Blazor's <EditForm></EditForm> component
|
|
/// </summary>
|
|
[CascadingParameter]
|
|
EditContext EditContext { get; set; } = default!;
|
|
|
|
/// <summary>
|
|
/// Triggers field to be validated.
|
|
/// </summary>
|
|
internal void EditFormValidate()
|
|
{
|
|
if (_fieldIdentifier.FieldName != null)
|
|
{
|
|
EditContext?.NotifyFieldChanged(_fieldIdentifier);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Specify an expression which returns the model's field for which validation messages should be displayed.
|
|
/// </summary>
|
|
[Parameter]
|
|
public Expression<Func<T>>? For { get; set; }
|
|
|
|
|
|
public bool IsForNull => For == null;
|
|
|
|
|
|
/// <summary>
|
|
/// Stores the list of validation attributes attached to the property targeted by <seealso cref="For"/>. If <seealso cref="For"/> is null, this property is null too.
|
|
/// </summary>
|
|
private IEnumerable<ValidationAttribute>? _validationAttrsFor;
|
|
|
|
|
|
private void OnValidationStateChanged(object sender, ValidationStateChangedEventArgs e)
|
|
{
|
|
if (EditContext != null && !_fieldIdentifier.Equals(default(FieldIdentifier)))
|
|
{
|
|
var error_msgs = EditContext.GetValidationMessages(_fieldIdentifier).ToArray();
|
|
HasError = error_msgs.Length > 0;
|
|
ErrorText = (HasError ? error_msgs[0] : null);
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Points to a field of the model for which validation messages should be displayed.
|
|
/// </summary>
|
|
private FieldIdentifier _fieldIdentifier;
|
|
|
|
/// <summary>
|
|
/// To find out whether or not For parameter has changed we keep a separate reference
|
|
/// </summary>
|
|
private Expression<Func<T>>? _currentFor;
|
|
|
|
|
|
/// <summary>
|
|
/// To find out whether or not EditContext parameter has changed we keep a separate reference
|
|
/// </summary>
|
|
private EditContext? _currentEditContext;
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
if (For != null && For != _currentFor)
|
|
{
|
|
// Extract validation attributes
|
|
// Sourced from https://stackoverflow.com/a/43076222/4839162
|
|
// and also https://stackoverflow.com/questions/59407225/getting-a-custom-attribute-from-a-property-using-an-expression
|
|
var expression = (MemberExpression)For.Body;
|
|
var propertyInfo = (PropertyInfo)expression.Expression?.Type.GetProperty(expression.Member.Name);
|
|
_validationAttrsFor = propertyInfo?.GetCustomAttributes(typeof(ValidationAttribute), true).Cast<ValidationAttribute>();
|
|
|
|
_fieldIdentifier = FieldIdentifier.Create(For);
|
|
_currentFor = For;
|
|
}
|
|
|
|
if (EditContext != null && EditContext != _currentEditContext)
|
|
{
|
|
DetachValidationStateChangedListener();
|
|
EditContext.OnValidationStateChanged += OnValidationStateChanged;
|
|
_currentEditContext = EditContext;
|
|
}
|
|
}
|
|
|
|
private void DetachValidationStateChangedListener()
|
|
{
|
|
if (_currentEditContext != null)
|
|
_currentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
|
|
}
|
|
|
|
#endregion
|
|
|
|
|
|
protected override Task OnInitializedAsync()
|
|
{
|
|
RegisterAsFormComponent();
|
|
return base.OnInitializedAsync();
|
|
}
|
|
|
|
protected virtual void RegisterAsFormComponent()
|
|
{
|
|
if (SubscribeToParentForm)
|
|
{
|
|
Form?.Add(this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called to dispose this instance.
|
|
/// </summary>
|
|
/// <param name="disposing"><see langword="true"/> if called within <see cref="IDisposable.Dispose"/>.</param>
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
try
|
|
{
|
|
Form?.Remove(this);
|
|
}
|
|
catch { /* ignore */ }
|
|
DetachValidationStateChangedListener();
|
|
Dispose(disposing: true);
|
|
}
|
|
}
|