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 : UIComponent, IFormComponent, IDisposable { private Converter _converter; protected FormComponent(Converter converter) { _converter = converter ?? throw new ArgumentNullException(nameof(converter)); _converter.OnError = OnConversionError; } [CascadingParameter] internal IForm Form { get; set; } /// /// 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!! /// [CascadingParameter(Name = "SubscribeToParentForm")] internal bool SubscribeToParentForm { get; set; } = true; /// /// If true, this form input is required to be filled out. /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public bool Required { get; set; } /// /// The error text that will be displayed if the input is not filled out but required. /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public string RequiredError { get; set; } = "Required"; /// /// The ErrorText that will be displayed if Error true. /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public string ErrorText { get; set; } /// /// If true, the label will be displayed in an error state. /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public bool Error { get; set; } /// /// The ErrorId that will be used by aria-describedby if Error true /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public string ErrorId { get; set; } /// /// The generic converter of the component. /// [Parameter] [Category(CategoryTypes.FormComponent.Behavior)] public Converter Converter { get => _converter; set => SetConverter(value); } protected virtual bool SetConverter(Converter value) { var changed = (_converter != value); if (changed) { _converter = value ?? throw new ArgumentNullException(nameof(value)); // converter is mandatory at all times _converter.OnError = OnConversionError; } return changed; } /// /// The culture of the component. /// [Parameter] [Category(CategoryTypes.FormComponent.Behavior)] public CultureInfo Culture { get => _converter.Culture; set => SetCulture(value); } 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 Touched = true; Form?.Update(this); OnConversionErrorOccurred(error); } protected virtual void OnConversionErrorOccurred(string error) { /* Descendants can override this method to catch conversion errors */ } /// /// True if the conversion from string to T failed /// public bool ConversionError => _converter.GetError; /// /// The error message of the conversion error from string to T. Null otherwise /// public string ConversionErrorMessage => _converter.GetErrorMessage; /// /// True if the input has any of the following errors: An error set from outside, a conversion error or /// one or more validation errors /// public bool HasErrors => Error || ConversionError || ValidationErrors.Count > 0; /// /// Return the validation error text or the conversion error message. /// /// Error text/message 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; } /// /// This manages the state of having been "touched" by the user. A form control always starts out untouched /// but becomes touched when the user performed input or the blur event was raised. /// /// The touched state is only relevant for inputs that have no value (i.e. empty text fields). Being untouched will /// suppress RequiredError /// public bool Touched { get; protected set; } #region MudForm Validation public List ValidationErrors { get; set; } = new List(); /// /// A validation func or a validation attribute. Supported types are: /// Func<T, bool> ... will output the standard error message "Invalid" if false /// Func<T, string> ... outputs the result as error message, no error if null /// Func<T, IEnumerable< string >> ... outputs all the returned error messages, no error if empty /// Func<object, string, IEnumerable< string >> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty /// Func<T, Task< bool >> ... will output the standard error message "Invalid" if false /// Func<T, Task< string >> ... outputs the result as error message, no error if null /// Func<T, Task<IEnumerable< string >>> ... outputs all the returned error messages, no error if empty /// Func<object, string, Task<IEnumerable< string >>> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty /// System.ComponentModel.DataAnnotations.ValidationAttribute instances /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public object Validation { get; set; } /// /// This is the form component's value. /// protected T _value; // 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 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.Default.Equals(value, _value)) { BeginValidate(); } }; execute().AndForget(); } protected void BeginValidate() { Func execute = async () => { var value = _value; await ValidateValue(); if (EqualityComparer.Default.Equals(value, _value)) { EditFormValidate(); } }; execute().AndForget(); } /// /// Cause this component to validate its value. /// 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). Touched = true; return ValidateValue(); } protected virtual async Task ValidateValue() { var changed = false; var errors = new List(); 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) ValidateWithFunc(Validation as Func, _value, errors); else if (Validation is Func) ValidateWithFunc(Validation as Func, _value, errors); else if (Validation is Func>) ValidateWithFunc(Validation as Func>, _value, errors); else if (Validation is Func>) ValidateModelWithFullPathOfMember(Validation as Func>, errors); else { var value = _value; if (Validation is Func>) await ValidateWithFunc(Validation as Func>, _value, errors); else if (Validation is Func>) await ValidateWithFunc(Validation as Func>, _value, errors); else if (Validation is Func>>) await ValidateWithFunc(Validation as Func>>, _value, errors); else if (Validation is Func>>) await ValidateModelWithFullPathOfMember(Validation as Func>>, errors); changed = !EqualityComparer.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 (Touched && !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; Error = 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 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 func, T value, List errors) { try { if (!func(value)) errors.Add("Invalid"); } catch (Exception e) { errors.Add("Error in validation func: " + e.Message); } } protected virtual void ValidateWithFunc(Func func, T value, List 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> func, T value, List 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> func, List 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> func, T value, List 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> func, T value, List 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>> func, T value, List 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>> func, List 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); } } /// /// Notify the Form that a field has changed if SubscribeToParentForm is true /// protected void FieldChanged(object newValue) { if (SubscribeToParentForm) Form?.FieldChanged(this, newValue); } /// /// Reset the value and the validation. /// public void Reset() { ResetValue(); ResetValidation(); } protected virtual void ResetValue() { /* to be overridden */ _value = default; Touched = false; StateHasChanged(); } /// /// Reset the validation. /// public void ResetValidation() { Error = false; ValidationErrors.Clear(); ErrorText = null; StateHasChanged(); } #endregion #region --> Blazor EditForm validation support /// /// This is the form validation context for Blazor's component /// [CascadingParameter] EditContext EditContext { get; set; } = default!; /// /// Triggers field to be validated. /// internal void EditFormValidate() { if (_fieldIdentifier.FieldName != null) { EditContext?.NotifyFieldChanged(_fieldIdentifier); } } /// /// Specify an expression which returns the model's field for which validation messages should be displayed. /// #nullable enable [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public Expression>? For { get; set; } #nullable disable public bool IsForNull => For == null; /// /// Stores the list of validation attributes attached to the property targeted by . If is null, this property is null too. /// #nullable enable private IEnumerable? _validationAttrsFor; #nullable disable private void OnValidationStateChanged(object sender, ValidationStateChangedEventArgs e) { if (EditContext != null && !_fieldIdentifier.Equals(default(FieldIdentifier))) { var error_msgs = EditContext.GetValidationMessages(_fieldIdentifier).ToArray(); Error = error_msgs.Length > 0; ErrorText = (Error ? error_msgs[0] : null); StateHasChanged(); } } /// /// Points to a field of the model for which validation messages should be displayed. /// private FieldIdentifier _fieldIdentifier; /// /// To find out whether or not For parameter has changed we keep a separate reference /// #nullable enable private Expression>? _currentFor; #nullable disable /// /// To find out whether or not EditContext parameter has changed we keep a separate reference /// #nullable enable private EditContext? _currentEditContext; #nullable disable 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(); _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); } } /// /// Called to dispose this instance. /// /// if called within . protected virtual void Dispose(bool disposing) { } void IDisposable.Dispose() { try { Form?.Remove(this); } catch { /* ignore */ } DetachValidationStateChangedListener(); Dispose(disposing: true); } }