From d1b9d07f28112f3a64e834d66c43a891560cfff3 Mon Sep 17 00:00:00 2001 From: capdiem Date: Thu, 1 Aug 2024 17:58:38 +0800 Subject: [PATCH] (Form): splitting DataAnnotations and FluentValidation, auto label generating and built-in complex type validation --- .../Pages/FormTest3.razor | 0 .../Components/Form/AutoLabelOptions.cs | 56 ++++ .../Form/EditContext/EditContextExtensions.cs | 16 - .../ValidationEventSubscriptions.cs | 298 ------------------ .../Form/{EditContext => }/FormContext.cs | 13 + .../Form/FormInputLabelAutoGenerator.cs | 130 ++++++++ src/Masa.Blazor/Components/Form/MForm.razor | 11 + .../Components/Form/MForm.razor.cs | 112 ++++++- .../EnumerableValidationAttribute.cs | 5 +- .../EnumerableValidationResult.cs | 2 + .../Validation/FluentValidationValidator.cs | 212 +++++++++++++ .../ObjectGraphDataAnnotationsValidator.cs | 175 ++++++++++ .../ValidateComplexTypeAttribute.cs | 26 ++ .../Form/{ => Validation}/ValidationResult.cs | 0 .../{ => Validation}/ValidationResultTypes.cs | 0 src/Masa.Blazor/Components/Input/IInput.cs | 6 + .../Components/Input/IValidatable.cs | 2 +- .../Components/Input/MInput.Validatable.cs | 18 +- src/Masa.Blazor/Components/Input/MInput.razor | 2 +- .../Components/Input/MInput.razor.cs | 16 +- .../Components/Radio/MRadioGroup.razor | 2 +- .../Components/TextField/MTextField.razor | 2 +- .../Components/TextField/MTextField.razor.cs | 2 +- 23 files changed, 751 insertions(+), 355 deletions(-) create mode 100644 src/Masa.Blazor.Playground/Pages/FormTest3.razor create mode 100644 src/Masa.Blazor/Components/Form/AutoLabelOptions.cs delete mode 100644 src/Masa.Blazor/Components/Form/EditContext/EditContextExtensions.cs delete mode 100644 src/Masa.Blazor/Components/Form/EditContext/ValidationEventSubscriptions.cs rename src/Masa.Blazor/Components/Form/{EditContext => }/FormContext.cs (71%) create mode 100644 src/Masa.Blazor/Components/Form/FormInputLabelAutoGenerator.cs rename src/Masa.Blazor/Components/Form/{ => Validation}/EnumerableValidationAttribute.cs (82%) rename src/Masa.Blazor/Components/Form/{ => Validation}/EnumerableValidationResult.cs (96%) create mode 100644 src/Masa.Blazor/Components/Form/Validation/FluentValidationValidator.cs create mode 100644 src/Masa.Blazor/Components/Form/Validation/ObjectGraphDataAnnotationsValidator.cs create mode 100644 src/Masa.Blazor/Components/Form/Validation/ValidateComplexTypeAttribute.cs rename src/Masa.Blazor/Components/Form/{ => Validation}/ValidationResult.cs (100%) rename src/Masa.Blazor/Components/Form/{ => Validation}/ValidationResultTypes.cs (100%) create mode 100644 src/Masa.Blazor/Components/Input/IInput.cs diff --git a/src/Masa.Blazor.Playground/Pages/FormTest3.razor b/src/Masa.Blazor.Playground/Pages/FormTest3.razor new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Masa.Blazor/Components/Form/AutoLabelOptions.cs b/src/Masa.Blazor/Components/Form/AutoLabelOptions.cs new file mode 100644 index 0000000000..3f228106a7 --- /dev/null +++ b/src/Masa.Blazor/Components/Form/AutoLabelOptions.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using IComponent = Microsoft.AspNetCore.Components.IComponent; + +namespace Masa.Blazor.Components.Form; + +/// +/// The settings for automatically generating the label of the form input. +/// Should be placed in the component. +/// +public class AutoLabelOptions : IComponent +{ + [CascadingParameter] private MForm? Form { get; set; } + + /// + /// The attribute type to get the display name. + /// Supported types are and . + /// + [Parameter] + public Type? AttributeType { get; set; } = typeof(DisplayNameAttribute); + + private bool _init; + + public void Attach(RenderHandle renderHandle) + { + } + + public Task SetParametersAsync(ParameterView parameters) + { + if (_init) + { + return Task.CompletedTask; + } + + _init = true; + + if (parameters.TryGetValue(nameof(Form), out MForm? form)) + { + Form = form; + } + + if (Form is null) + { + return Task.CompletedTask; + } + + if (parameters.TryGetValue(nameof(AttributeType), out Type? attributeType)) + { + AttributeType = attributeType; + } + + Form.LabelAttributeType = AttributeType; + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/EditContext/EditContextExtensions.cs b/src/Masa.Blazor/Components/Form/EditContext/EditContextExtensions.cs deleted file mode 100644 index 9f8342d463..0000000000 --- a/src/Masa.Blazor/Components/Form/EditContext/EditContextExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Masa.Blazor; - -public static class EditContextExtensions -{ - /// - /// Enables DataAnnotations validation support for the . - /// - /// The . - /// - /// - /// A disposable object whose disposal will remove DataAnnotations validation support from the . - public static IDisposable EnableValidation(this EditContext editContext, ValidationMessageStore messageStore, IServiceProvider serviceProvider, bool enableI18n) - { - return new ValidationEventSubscriptions(editContext, messageStore, serviceProvider, enableI18n); - } -} diff --git a/src/Masa.Blazor/Components/Form/EditContext/ValidationEventSubscriptions.cs b/src/Masa.Blazor/Components/Form/EditContext/ValidationEventSubscriptions.cs deleted file mode 100644 index 9b5e76ccba..0000000000 --- a/src/Masa.Blazor/Components/Form/EditContext/ValidationEventSubscriptions.cs +++ /dev/null @@ -1,298 +0,0 @@ -using FluentValidation; -using Microsoft.Extensions.DependencyInjection; -using System.Collections.Concurrent; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Util.Reflection.Expressions; -using Util.Reflection.Expressions.Abstractions; -using FluentValidationResult = FluentValidation.Results.ValidationResult; -using ValidationResult = System.ComponentModel.DataAnnotations.ValidationResult; - -namespace Masa.Blazor; - -internal sealed class ValidationEventSubscriptions : IDisposable -{ - private static readonly ConcurrentDictionary ModelFluentValidatorMap = new(); - private static readonly ConcurrentDictionary>> ModelPropertiesMap = new(); - private static readonly Dictionary FluentValidationTypeMap = new(); - private static readonly ConcurrentDictionary DisplayNameMap = new(); - - static ValidationEventSubscriptions() - { - try - { - var referenceAssembles = AppDomain.CurrentDomain.GetAssemblies(); - foreach (var referenceAssembly in referenceAssembles) - { - if (referenceAssembly!.FullName!.StartsWith("Microsoft.") || referenceAssembly.FullName.StartsWith("System.")) - continue; - - var types = referenceAssembly - .GetTypes() - .Where(t => t.IsClass) - .Where(t => !t.IsAbstract) - .Where(t => typeof(IValidator).IsAssignableFrom(t)) - .ToArray(); - - foreach (var type in types) - { - var modelType = type!.BaseType!.GenericTypeArguments[0]; - var validatorType = typeof(IValidator<>).MakeGenericType(modelType); - FluentValidationTypeMap.Add(modelType, validatorType); - } - } - } - catch - { - // ignored - } - } - - private readonly EditContext _editContext; - private readonly ValidationMessageStore _messageStore; - private readonly IServiceProvider _serviceProvider; - private I18n? _i18n; - - [MemberNotNullWhen(true, nameof(_i18n))] - private bool EnableI18n { get; set; } - - public ValidationEventSubscriptions(EditContext editContext, ValidationMessageStore messageStore, IServiceProvider serviceProvider, - bool enableI18n) - { - _serviceProvider = serviceProvider; - _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext)); - _messageStore = messageStore; - - _editContext.OnFieldChanged += OnFieldChanged; - _editContext.OnValidationRequested += OnValidationRequested; - EnableI18n = enableI18n; - if (EnableI18n) - { - _i18n = _serviceProvider.GetService(); - } - } - - private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs) - { - Validate(eventArgs.FieldIdentifier); - } - - private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e) - { - Validate(new FieldIdentifier(new(), "")); - } - - private void Validate(FieldIdentifier field) - { - if (FluentValidationTypeMap.ContainsKey(_editContext.Model.GetType())) - { - FluentValidate(_editContext.Model, _messageStore, field); - } - else - { - DataAnnotationsValidate(_editContext.Model, _messageStore, field); - } - - _editContext.NotifyValidationStateChanged(); - } - - private void DataAnnotationsValidate(object model, ValidationMessageStore messageStore, FieldIdentifier field) - { - var validationResults = new List(); - if (field.FieldName == "") - { - var validationContext = new ValidationContext(model); - Validator.TryValidateObject(model, validationContext, validationResults, true); - messageStore.Clear(); - - foreach (var validationResult in validationResults) - { - if (validationResult is EnumerableValidationResult enumerableValidationResult) - { - foreach (var descriptor in enumerableValidationResult.Descriptors) - { - foreach (var result in descriptor.Results) - { - foreach (var memberName in result.MemberNames) - { - AddValidationMessage(new FieldIdentifier(descriptor.ObjectInstance, memberName), result.ErrorMessage!); - } - } - } - } - else - { - foreach (var memberName in validationResult.MemberNames) - { - AddValidationMessage(new FieldIdentifier(model, memberName), validationResult.ErrorMessage); - } - } - } - } - else - { - var validationContext = new ValidationContext(field.Model); - Validator.TryValidateObject(field.Model, validationContext, validationResults, true); - messageStore.Clear(field); - foreach (var validationResult in validationResults) - { - if (validationResult.MemberNames.Contains(field.FieldName)) - { - AddValidationMessage(field, validationResult.ErrorMessage); - return; - } - } - } - } - - private void FluentValidate(object model, ValidationMessageStore messageStore, FieldIdentifier field) - { - var validationResult = GetValidationResult(model); - if (validationResult is null) return; - - if (field.FieldName == "") - { - messageStore.Clear(); - var propertyMap = GetPropertyMap(model); - foreach (var error in validationResult.Errors) - { - if (error.PropertyName.Contains(".")) - { - var propertyName = error.PropertyName.Substring(0, error.PropertyName.LastIndexOf('.')); - if (propertyMap.TryGetValue(propertyName, out var modelItem)) - { - var modelItemPropertyName = error.PropertyName.Split('.').Last(); - AddValidationMessage(new FieldIdentifier(modelItem, modelItemPropertyName), error.ErrorMessage); - } - } - else - { - AddValidationMessage(new FieldIdentifier(model, error.PropertyName), error.ErrorMessage); - } - } - } - else - { - messageStore.Clear(field); - if (field.Model == model) - { - var error = validationResult.Errors.FirstOrDefault(e => e.PropertyName == field.FieldName); - if (error is not null) - { - AddValidationMessage(field, error.ErrorMessage); - } - } - else - { - var propertyMap = GetPropertyMap(model); - var key = propertyMap.FirstOrDefault(pm => pm.Value == field.Model).Key; - var errorMessage = validationResult.Errors.FirstOrDefault(e => e.PropertyName == ($"{key}.{field.FieldName}"))?.ErrorMessage; - if (errorMessage is not null) - { - AddValidationMessage(field, errorMessage); - } - } - } - } - - private FluentValidationResult? GetValidationResult(object model) - { - var type = model.GetType(); - - if (FluentValidationTypeMap.TryGetValue(type, out var validatorType)) - { - var validationContext = new ValidationContext(model); - if (!ModelFluentValidatorMap.TryGetValue(type, out var validator)) - { - validator = (IValidator?)_serviceProvider.GetService(validatorType); - if (validator is not null) - { - ModelFluentValidatorMap.TryAdd(type, validator); - } - } - - return validator?.Validate(validationContext); - } - - throw new NotImplementedException($"Validator for {type} does not exists."); - } - - private Dictionary GetPropertyMap(object model) - { - var type = model.GetType(); - if (ModelPropertiesMap.TryGetValue(type, out var func) is false) - { - var modelParameter = Expr.BlockParam().Convert(type); - Var map = Expr.New>(); - BuildPropertyMap(modelParameter, map); - - func = map.BuildDelegate>>(); - ModelPropertiesMap[type] = func; - } - - return func(model); - - void BuildPropertyMap(CommonValueExpression value, Var map, CommonValueExpression? basePropertyPath = null) - { - basePropertyPath ??= Expr.Constant(""); - var properties = value.Type.GetProperties(); - foreach (var property in properties) - { - if (property.PropertyType.IsValueType || property.PropertyType == typeof(string)) - continue; - - if (property.PropertyType.GetInterfaces().Any(gt => gt == typeof(System.Collections.IEnumerable))) - { - Var index = -1; - Expr.Foreach(value[property.Name], (item, _, _) => - { - index++; - var propertyPath = basePropertyPath + property.Name + "[" + index + "]"; - map[propertyPath] = item.Convert(); - BuildPropertyMap(item, map, propertyPath + "."); - }); - } - else - { - var propertyPath = basePropertyPath + property.Name; - map[propertyPath] = value[property.Name].Convert(); - BuildPropertyMap(value[property.Name], map, $"{propertyPath}."); - } - } - } - } - - private void AddValidationMessage(in FieldIdentifier fieldIdentifier, string? message) - { - if (message == null) return; - - if (EnableI18n) - { - var key = $"{fieldIdentifier.Model.GetType().FullName}.{_i18n.Culture.Name}.{fieldIdentifier.FieldName}"; - if (DisplayNameMap.TryGetValue(key, out var displayName) is false) - { - displayName = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName)!.GetCustomAttribute()?.DisplayName; - if (displayName is not null) - { - displayName = _i18n.T(displayName); - } - - DisplayNameMap.TryAdd(key, displayName); - } - - message = _i18n.T(message, args: displayName ?? fieldIdentifier.FieldName); - } - - _messageStore.Add(fieldIdentifier, message); - } - - public void Dispose() - { - _messageStore.Clear(); - _editContext.OnFieldChanged -= OnFieldChanged; - _editContext.OnValidationRequested -= OnValidationRequested; - _editContext.NotifyValidationStateChanged(); - } -} diff --git a/src/Masa.Blazor/Components/Form/EditContext/FormContext.cs b/src/Masa.Blazor/Components/Form/FormContext.cs similarity index 71% rename from src/Masa.Blazor/Components/Form/EditContext/FormContext.cs rename to src/Masa.Blazor/Components/Form/FormContext.cs index c65b0eca10..deca61e9b5 100644 --- a/src/Masa.Blazor/Components/Form/EditContext/FormContext.cs +++ b/src/Masa.Blazor/Components/Form/FormContext.cs @@ -1,4 +1,5 @@ using Masa.Blazor.Components.Form; +using Masa.Blazor.Components.Input; namespace Masa.Blazor; @@ -21,10 +22,22 @@ public FormContext(EditContext editContext, MForm form) public bool Validate() => Form.Validate(); + public bool Validate(FieldIdentifier fieldIdentifier) => Form.Validate(fieldIdentifier); + + public bool Validate(IValidatable validatable) => Form.Validate(validatable); + public void Reset() => Form.Reset(); + public void Reset(FieldIdentifier fieldIdentifier) => Form.Reset(fieldIdentifier); + + public void Reset(IValidatable validatable) => Form.Reset(validatable); + public void ResetValidation() => Form.ResetValidation(); + public void ResetValidation(FieldIdentifier fieldIdentifier) => Form.ResetValidation(fieldIdentifier); + + public void ResetValidation(IValidatable validatable) => Form.ResetValidation(validatable); + public bool IsValid => Form.Value; /// diff --git a/src/Masa.Blazor/Components/Form/FormInputLabelAutoGenerator.cs b/src/Masa.Blazor/Components/Form/FormInputLabelAutoGenerator.cs new file mode 100644 index 0000000000..bde8c32e37 --- /dev/null +++ b/src/Masa.Blazor/Components/Form/FormInputLabelAutoGenerator.cs @@ -0,0 +1,130 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Masa.Blazor.Components.Input; +using IComponent = Microsoft.AspNetCore.Components.IComponent; + +namespace Masa.Blazor.Components.Form; + +public class FormInputLabelAutoGenerator : IComponent, IDisposable +{ + [Inject] private I18n I18n { get; set; } = null!; + + [CascadingParameter] private MForm Form { get; set; } = null!; + + private bool _init; + private Dictionary _fullNameMap = new(); + + private bool EnableI18n => Form.EnableI18n; + + public void Attach(RenderHandle renderHandle) + { + // Do nothing + } + + public Task SetParametersAsync(ParameterView parameters) + { + if (_init) + { + return Task.CompletedTask; + } + + _init = true; + I18n.CultureChanged += I18nOnCultureChanged; + + Form = parameters.GetValueOrDefault(nameof(Form)) ?? + throw new InvalidOperationException("Form is null."); + + if (Form.Model is null) + { + return Task.CompletedTask; + } + + Form.OnValidatableChanged += OnValidatableChanged; + + foreach (var input in Form.Validatables) + { + if (input.ValueIdentifier.HasValue) + { + SetLabel(input); + } + } + + return Task.CompletedTask; + } + + private void I18nOnCultureChanged(object? sender, EventArgs e) + { + foreach (var input in Form.Validatables) + { + if (input.ValueIdentifier.HasValue) + { + SetLabel(input); + } + } + } + + private void OnValidatableChanged(object? sender, MForm.ValidatableChangedEventArgs e) + { + if (e.Type == MForm.ValidatableChangedType.Register) + { + SetLabel(e.Validatable); + } + } + + private void SetLabel(IValidatable validatable) + { + var attributeType = Form.LabelAttributeType; + + if (attributeType is null || !validatable.ValueIdentifier.HasValue) + { + return; + } + + var model = validatable.ValueIdentifier.Value.Model; + var fieldName = validatable.ValueIdentifier.Value.FieldName; + var attribute = model.GetType().GetProperty(fieldName)?.GetCustomAttribute(attributeType); + + string? displayName = null; + switch (attribute) + { + case DisplayAttribute displayAttribute: + displayName = displayAttribute.Name; + break; + case DisplayNameAttribute displayNameAttribute: + displayName = displayNameAttribute.DisplayName; + break; + } + + if (displayName is null) + { + return; + } + + if (validatable is IInput stringInput) + { + var label = EnableI18n ? I18n.T(displayName) : displayName; + stringInput.SetFormLabel(label); + + var modelFullName = GetOrSetFullName(model); + var key = $"{modelFullName}.{fieldName}"; + Form.AutoLabelMap[key] = label; + } + } + + private string GetOrSetFullName(object value) + { + if (!_fullNameMap.TryGetValue(value, out var fullName)) + { + fullName = value.GetType().FullName; + _fullNameMap[value] = fullName; + } + + return fullName; + } + + public void Dispose() + { + Form.OnValidatableChanged -= OnValidatableChanged; + } +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/MForm.razor b/src/Masa.Blazor/Components/Form/MForm.razor index 965364b7b4..61d1c1d2fb 100644 --- a/src/Masa.Blazor/Components/Form/MForm.razor +++ b/src/Masa.Blazor/Components/Form/MForm.razor @@ -9,6 +9,17 @@ @onsubmit="HandleOnSubmitAsync" @attributes="@Attributes"> @ChildContent?.Invoke(FormContext!) + + @if (AutoLabel) + { + + } + + @if (EditContext is not null) + { + + + } \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/MForm.razor.cs b/src/Masa.Blazor/Components/Form/MForm.razor.cs index 8945462483..5ab5fbd51e 100644 --- a/src/Masa.Blazor/Components/Form/MForm.razor.cs +++ b/src/Masa.Blazor/Components/Form/MForm.razor.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.ComponentModel; using System.Reflection; using Masa.Blazor.Components.Form; using Masa.Blazor.Components.Input; @@ -7,8 +8,6 @@ namespace Masa.Blazor; public partial class MForm : MasaComponentBase { - [Inject] public IServiceProvider ServiceProvider { get; set; } = null!; - [Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public EventCallback OnSubmit { get; set; } @@ -33,19 +32,30 @@ public partial class MForm : MasaComponentBase [Parameter] public EventCallback OnInvalidSubmit { get; set; } + [Parameter] + [MasaApiParameter(true, ReleasedOn = "v1.7.0")] + public bool AutoLabel { get; set; } = true; + + internal ConcurrentDictionary AutoLabelMap { get; } = new(); + private static readonly ConcurrentDictionary s_modelPropertiesMap = new(); private object? _oldModel; - private IDisposable? _editContextValidation; public EditContext? EditContext { get; protected set; } public FormContext? FormContext { get; private set; } - private ValidationMessageStore? ValidationMessageStore { get; set; } + internal ValidationMessageStore? ValidationMessageStore { get; private set; } + + public List Validatables { get; } = new(); + + /// + /// The type of property attribute used to generate the label, + /// the default value is same as . + /// + internal Type? LabelAttributeType { get; set; } = typeof(DisplayNameAttribute); - private List Validatables { get; } = new(); - protected override void OnParametersSet() { base.OnParametersSet(); @@ -54,9 +64,7 @@ protected override void OnParametersSet() { EditContext = new EditContext(Model); FormContext = new FormContext(EditContext, this); - ValidationMessageStore = new ValidationMessageStore(EditContext); - _editContextValidation = EditContext.EnableValidation(ValidationMessageStore, ServiceProvider, EnableI18n); _oldModel = Model; } @@ -78,14 +86,41 @@ internal void UpdateValidValue() _ = UpdateValue(valid); } - public void Register(IValidatable validatable) + /// + /// Register a validatable component to the form + /// + /// + internal void Register(IValidatable validatable) { Validatables.Add(validatable); + OnValidatableChanged?.Invoke(this, + new ValidatableChangedEventArgs(validatable, ValidatableChangedType.Register)); } + /// + /// Unregister a validatable component from the form + /// + /// internal void Remove(IValidatable validatable) { Validatables.Remove(validatable); + OnValidatableChanged?.Invoke(this, + new ValidatableChangedEventArgs(validatable, ValidatableChangedType.Unregister)); + } + + public event EventHandler? OnValidatableChanged; + + public enum ValidatableChangedType + { + Register, + Unregister + } + + public class ValidatableChangedEventArgs(IValidatable validatable, ValidatableChangedType type) : EventArgs + { + public IValidatable Validatable { get; } = validatable; + + public ValidatableChangedType Type { get; } = type; } private async Task HandleOnSubmitAsync(EventArgs args) @@ -130,7 +165,7 @@ public bool Validate() valid = false; } } - + if (EditContext != null) { var success = EditContext.Validate(); @@ -337,6 +372,31 @@ public void Reset() _ = UpdateValue(true); } + [MasaApiPublicMethod] + public void Reset(FieldIdentifier fieldIdentifier) + { + var validatable = Validatables.FirstOrDefault(item => item.ValueIdentifier.Equals(fieldIdentifier)); + if (validatable is null) + { + throw new ArgumentException($"Field {fieldIdentifier.FieldName} not found in form."); + } + + Reset(validatable); + } + + [MasaApiPublicMethod] + public void Reset(IValidatable validatable) + { + if (validatable.ValueIdentifier.HasValue) + { + EditContext?.MarkAsUnmodified(validatable.ValueIdentifier.Value); + } + + validatable.Reset(); + + UpdateValidValue(); + } + [MasaApiPublicMethod] public void ResetValidation() { @@ -349,6 +409,31 @@ public void ResetValidation() _ = UpdateValue(true); } + + [MasaApiPublicMethod] + public void ResetValidation(FieldIdentifier fieldIdentifier) + { + var validatable = Validatables.FirstOrDefault(item => item.ValueIdentifier.Equals(fieldIdentifier)); + if (validatable is null) + { + throw new ArgumentException($"Field {fieldIdentifier.FieldName} not found in form."); + } + + ResetValidation(validatable); + } + + [MasaApiPublicMethod] + public void ResetValidation(IValidatable validatable) + { + if (validatable.ValueIdentifier.HasValue) + { + EditContext?.MarkAsUnmodified(validatable.ValueIdentifier.Value); + } + + validatable.ResetValidation(); + + UpdateValidValue(); + } private async Task UpdateValue(bool val) { @@ -362,11 +447,4 @@ private async Task UpdateValue(bool val) StateHasChanged(); } } - - protected override async ValueTask DisposeAsyncCore() - { - _editContextValidation?.Dispose(); - - await base.DisposeAsyncCore(); - } } \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/EnumerableValidationAttribute.cs b/src/Masa.Blazor/Components/Form/Validation/EnumerableValidationAttribute.cs similarity index 82% rename from src/Masa.Blazor/Components/Form/EnumerableValidationAttribute.cs rename to src/Masa.Blazor/Components/Form/Validation/EnumerableValidationAttribute.cs index 2ba19de014..4f5e524bfc 100644 --- a/src/Masa.Blazor/Components/Form/EnumerableValidationAttribute.cs +++ b/src/Masa.Blazor/Components/Form/Validation/EnumerableValidationAttribute.cs @@ -3,7 +3,8 @@ namespace Masa.Blazor { - [Obsolete("Use System.ComponentModel.DataAnnotations.ValidateComplexType instead.")] + [Obsolete(message: "Use System.ComponentModel.DataAnnotations.ValidateComplexType instead.", + UrlFormat = "https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/validation?view=aspnetcore-8.0#nested-models-collection-types-and-complex-types")] [AttributeUsage(AttributeTargets.Property)] public class EnumerableValidationAttribute : ValidationAttribute { @@ -39,4 +40,4 @@ public class EnumerableValidationAttribute : ValidationAttribute return ValidationResult.Success; } } -} +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/EnumerableValidationResult.cs b/src/Masa.Blazor/Components/Form/Validation/EnumerableValidationResult.cs similarity index 96% rename from src/Masa.Blazor/Components/Form/EnumerableValidationResult.cs rename to src/Masa.Blazor/Components/Form/Validation/EnumerableValidationResult.cs index cd33ccbeb8..5e688cece5 100644 --- a/src/Masa.Blazor/Components/Form/EnumerableValidationResult.cs +++ b/src/Masa.Blazor/Components/Form/Validation/EnumerableValidationResult.cs @@ -2,6 +2,7 @@ namespace Masa.Blazor { + [Obsolete] public class EnumerableValidationResult : ValidationResult { public EnumerableValidationResult() @@ -13,6 +14,7 @@ public EnumerableValidationResult() public List Descriptors { get; } = new(); } + [Obsolete] public class ValidationResultDescriptor { public ValidationResultDescriptor(object objectInstance, List results) diff --git a/src/Masa.Blazor/Components/Form/Validation/FluentValidationValidator.cs b/src/Masa.Blazor/Components/Form/Validation/FluentValidationValidator.cs new file mode 100644 index 0000000000..7807dddc70 --- /dev/null +++ b/src/Masa.Blazor/Components/Form/Validation/FluentValidationValidator.cs @@ -0,0 +1,212 @@ +using FluentValidation; +using Util.Reflection.Expressions; +using Util.Reflection.Expressions.Abstractions; + +namespace Masa.Blazor.Components.Form; + +/// +/// Provides a way to validate a form using FluentValidation. Internal use only. +/// +public class FluentValidationValidator : ComponentBase, IDisposable +{ + private static readonly Dictionary FluentValidationTypeMap = new(); + private static readonly Dictionary ModelFluentValidatorMap = new(); + private static readonly Dictionary>> ModelPropertiesMap = new(); + + static FluentValidationValidator() + { + try + { + var referenceAssembles = AppDomain.CurrentDomain.GetAssemblies(); + foreach (var referenceAssembly in referenceAssembles) + { + if (referenceAssembly.FullName!.StartsWith("Microsoft.") || + referenceAssembly.FullName.StartsWith("System.")) + continue; + + var types = referenceAssembly + .GetTypes() + .Where(t => t.IsClass) + .Where(t => !t.IsAbstract) + .Where(t => typeof(IValidator).IsAssignableFrom(t)) + .ToArray(); + + foreach (var type in types) + { + var modelType = type.BaseType!.GenericTypeArguments[0]; + var validatorType = typeof(IValidator<>).MakeGenericType(modelType); + FluentValidationTypeMap.Add(modelType, validatorType); + } + } + } + catch + { + // ignored + } + } + + [Inject] private IServiceProvider ServiceProvider { get; set; } = null!; + + [Inject] private I18n I18n { get; set; } = null!; + + [CascadingParameter] internal EditContext EditContext { get; set; } = null!; + + [CascadingParameter] internal MForm Form { get; set; } = null!; + + private bool EnableI18n => Form.EnableI18n; + + protected override void OnInitialized() + { + if (!FluentValidationTypeMap.ContainsKey(EditContext.Model.GetType())) + { + return; + } + + EditContext.OnFieldChanged += EditContextOnOnFieldChanged; + EditContext.OnValidationRequested += EditContextOnOnValidationRequested; + } + + private void EditContextOnOnValidationRequested(object? sender, ValidationRequestedEventArgs e) + { + ValidateField(new FieldIdentifier(new(), string.Empty)); + } + + private void EditContextOnOnFieldChanged(object? sender, FieldChangedEventArgs e) + { + ValidateField(e.FieldIdentifier); + } + + private void ValidateField(FieldIdentifier fieldIdentifier) + { + var validationResult = GetValidationResult(); + if (validationResult is null) + { + return; + } + + if (fieldIdentifier.FieldName == "") + { + Form.ValidationMessageStore?.Clear(); + var propertyMap = GetPropertyMap(EditContext.Model); + foreach (var error in validationResult.Errors) + { + if (error.PropertyName.Contains(".")) + { + var propertyName = error.PropertyName.Substring(0, error.PropertyName.LastIndexOf('.')); + if (propertyMap.TryGetValue(propertyName, out var modelItem)) + { + var modelItemPropertyName = error.PropertyName.Split('.').Last(); + AddToMessageStore(new FieldIdentifier(modelItem, modelItemPropertyName), error.ErrorMessage); + } + } + else + { + AddToMessageStore(new FieldIdentifier(EditContext.Model, error.PropertyName), error.ErrorMessage); + } + } + } + else + { + Form.ValidationMessageStore!.Clear(fieldIdentifier); + if (fieldIdentifier.Model == EditContext.Model) + { + var error = validationResult.Errors.FirstOrDefault(e => e.PropertyName == fieldIdentifier.FieldName); + if (error is not null) + { + AddToMessageStore(fieldIdentifier, error.ErrorMessage); + } + } + else + { + var propertyMap = GetPropertyMap(EditContext.Model); + var key = propertyMap.FirstOrDefault(pm => pm.Value == fieldIdentifier.Model).Key; + var error = validationResult.Errors.FirstOrDefault(e => + e.PropertyName == ($"{key}.{fieldIdentifier.FieldName}")); + if (error is not null) + { + AddToMessageStore(fieldIdentifier, error.ErrorMessage); + } + } + } + + EditContext.NotifyValidationStateChanged(); + } + + private FluentValidation.Results.ValidationResult? GetValidationResult() + { + var type = EditContext.Model.GetType(); + + if (FluentValidationTypeMap.TryGetValue(type, out var validatorType)) + { + var validationContext = new ValidationContext(EditContext.Model); + if (!ModelFluentValidatorMap.TryGetValue(type, out var validator)) + { + validator = (IValidator?)ServiceProvider.GetService(validatorType); + if (validator is not null) + { + ModelFluentValidatorMap.TryAdd(type, validator); + } + } + + return validator?.Validate(validationContext); + } + + throw new NotImplementedException($"Validator for {type} does not exists."); + } + + private void AddToMessageStore(FieldIdentifier fieldIdentifier, string errorMessage) + { + Form.ValidationMessageStore!.Add(fieldIdentifier, EnableI18n ? I18n.T(errorMessage) : errorMessage); + } + + private Dictionary GetPropertyMap(object model) + { + var type = model.GetType(); + if (ModelPropertiesMap.TryGetValue(type, out var func) is false) + { + var modelParameter = Expr.BlockParam().Convert(type); + Var map = Expr.New>(); + BuildPropertyMap(modelParameter, map); + + func = map.BuildDelegate>>(); + ModelPropertiesMap[type] = func; + } + + return func(model); + + void BuildPropertyMap(CommonValueExpression value, Var map, CommonValueExpression? basePropertyPath = null) + { + basePropertyPath ??= Expr.Constant(""); + var properties = value.Type.GetProperties(); + foreach (var property in properties) + { + if (property.PropertyType.IsValueType || property.PropertyType == typeof(string)) + continue; + + if (property.PropertyType.GetInterfaces().Any(gt => gt == typeof(System.Collections.IEnumerable))) + { + Var index = -1; + Expr.Foreach(value[property.Name], (item, _, _) => + { + index++; + var propertyPath = basePropertyPath + property.Name + "[" + index + "]"; + map[propertyPath] = item.Convert(); + BuildPropertyMap(item, map, propertyPath + "."); + }); + } + else + { + var propertyPath = basePropertyPath + property.Name; + map[propertyPath] = value[property.Name].Convert(); + BuildPropertyMap(value[property.Name], map, $"{propertyPath}."); + } + } + } + } + + public void Dispose() + { + EditContext.OnValidationRequested += EditContextOnOnValidationRequested; + EditContext.OnFieldChanged += EditContextOnOnFieldChanged; + } +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/Validation/ObjectGraphDataAnnotationsValidator.cs b/src/Masa.Blazor/Components/Form/Validation/ObjectGraphDataAnnotationsValidator.cs new file mode 100644 index 0000000000..5fb69ea18b --- /dev/null +++ b/src/Masa.Blazor/Components/Form/Validation/ObjectGraphDataAnnotationsValidator.cs @@ -0,0 +1,175 @@ +using System.ComponentModel.DataAnnotations; + +namespace Masa.Blazor.Components.Form; + +// TODO: The properties in the form may not be displayed on the UI. +// In this case, should only the properties displayed on the UI be validated, +// or should all properties be validated? +// Validating all properties is simpler. + +/// +/// Provides a way to validate a form using DataAnnotations. Internal use only. +/// Source: https://github.com/dotnet/aspnetcore/blob/b89535c2cf78268e3b099ab92ff5b96c2d8b6f4f/src/Components/Blazor/Validation/src/ObjectGraphDataAnnotationsValidator.cs +/// +public class ObjectGraphDataAnnotationsValidator : ComponentBase, IDisposable +{ + private static readonly object ValidationContextValidatorKey = new object(); + private static readonly object ValidatedObjectsKey = new object(); + private static Dictionary valueFullNameMap = new(); + + [Inject] private I18n I18n { get; set; } = null!; + + [CascadingParameter] internal EditContext EditContext { get; set; } = null!; + + [CascadingParameter] internal MForm Form { get; set; } = null!; + + private bool EnableI18n => Form.EnableI18n; + + protected override void OnInitialized() + { + // Perform object-level validation (starting from the root model) on request + EditContext.OnValidationRequested += EditContextOnOnValidationRequested; + + // Perform per-field validation on each field edit + EditContext.OnFieldChanged += EditContextOnOnFieldChanged; + } + + private void EditContextOnOnFieldChanged(object? sender, FieldChangedEventArgs e) + { + ValidateField(EditContext, Form.ValidationMessageStore!, e.FieldIdentifier); + } + + private void EditContextOnOnValidationRequested(object? sender, ValidationRequestedEventArgs e) + { + Form.ValidationMessageStore?.Clear(); + ValidateObject(EditContext.Model, new HashSet()); + EditContext.NotifyValidationStateChanged(); + } + + + internal void ValidateObject(object value, HashSet visited) + { + if (value is null) + { + return; + } + + if (!visited.Add(value)) + { + // Already visited this object. + return; + } + + if (value is IEnumerable enumerable) + { + var index = 0; + foreach (var item in enumerable) + { + ValidateObject(item, visited); + index++; + } + + return; + } + + var validationResults = new List(); + ValidateObject(value, visited, validationResults); + + // Transfer results to the ValidationMessageStore + foreach (var validationResult in validationResults) + { + if (!validationResult.MemberNames.Any()) + { + Form.ValidationMessageStore!.Add(new FieldIdentifier(value, string.Empty), + GetFormatedErrorMessage(value, validationResult)); + continue; + } + + foreach (var memberName in validationResult.MemberNames) + { + var fieldIdentifier = new FieldIdentifier(value, memberName); + Form.ValidationMessageStore!.Add(fieldIdentifier, GetFormatedErrorMessage(value, validationResult)); + } + } + } + + private string? GetFormatedErrorMessage(object value, + System.ComponentModel.DataAnnotations.ValidationResult validationResult) + { + if (!EnableI18n) + { + return validationResult.ErrorMessage; + } + + if (!valueFullNameMap.TryGetValue(value, out var instanceFullName)) + { + instanceFullName = value.GetType().FullName; + valueFullNameMap[value] = instanceFullName; + } + + var memberName = validationResult.MemberNames.LastOrDefault(); + var autoLabelKey = $"{instanceFullName}.{memberName}"; + var label = memberName; + + if (Form?.AutoLabelMap.TryGetValue(autoLabelKey, out var autoLabel) is true) + { + label = autoLabel; + } + + return I18n.T(validationResult.ErrorMessage, args: label); + } + + private void ValidateObject(object value, HashSet visited, + List validationResults) + { + var validationContext = new ValidationContext(value); + validationContext.Items.Add(ValidationContextValidatorKey, this); + validationContext.Items.Add(ValidatedObjectsKey, visited); + Validator.TryValidateObject(value, validationContext, validationResults, validateAllProperties: true); + } + + internal static bool TryValidateRecursive(object value, ValidationContext validationContext) + { + if (validationContext.Items.TryGetValue(ValidationContextValidatorKey, out var result) && + result is ObjectGraphDataAnnotationsValidator validator) + { + var visited = (HashSet)validationContext.Items[ValidatedObjectsKey]; + validator.ValidateObject(value, visited); + + return true; + } + + return false; + } + + private void ValidateField(EditContext editContext, ValidationMessageStore messages, + in FieldIdentifier fieldIdentifier) + { + // DataAnnotations only validates public properties, so that's all we'll look for + var propertyInfo = fieldIdentifier.Model.GetType().GetProperty(fieldIdentifier.FieldName); + if (propertyInfo != null) + { + var model = fieldIdentifier.Model; + var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model); + var validationContext = new ValidationContext(fieldIdentifier.Model) + { + MemberName = propertyInfo.Name + }; + var results = new List(); + + Validator.TryValidateProperty(propertyValue, validationContext, results); + messages.Clear(fieldIdentifier); + messages.Add(fieldIdentifier, results.Select(result => GetFormatedErrorMessage(model, result))); + + // We have to notify even if there were no messages before and are still no messages now, + // because the "state" that changed might be the completion of some async validation task + editContext.NotifyValidationStateChanged(); + } + } + + public void Dispose() + { + EditContext.OnValidationRequested -= EditContextOnOnValidationRequested; + EditContext.OnFieldChanged -= EditContextOnOnFieldChanged; + } +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/Validation/ValidateComplexTypeAttribute.cs b/src/Masa.Blazor/Components/Form/Validation/ValidateComplexTypeAttribute.cs new file mode 100644 index 0000000000..747ec88122 --- /dev/null +++ b/src/Masa.Blazor/Components/Form/Validation/ValidateComplexTypeAttribute.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Masa.Blazor.Components.Form.DataAnnotations; + +/// +/// A that indicates that the property is a complex or collection type that further needs to be validated. +/// +/// By default does not recurse in to complex property types during validation. +/// When used in conjunction with , this property allows the validation system to validate +/// complex or collection type properties. +/// +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ValidateComplexTypeAttribute : ValidationAttribute +{ + /// + protected override System.ComponentModel.DataAnnotations.ValidationResult IsValid(object? value, ValidationContext validationContext) + { + if (!ObjectGraphDataAnnotationsValidator.TryValidateRecursive(value, validationContext)) + { + throw new InvalidOperationException($"{nameof(ValidateComplexTypeAttribute)} can only used with {nameof(ObjectGraphDataAnnotationsValidator)}."); + } + + return System.ComponentModel.DataAnnotations.ValidationResult.Success; + } +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Form/ValidationResult.cs b/src/Masa.Blazor/Components/Form/Validation/ValidationResult.cs similarity index 100% rename from src/Masa.Blazor/Components/Form/ValidationResult.cs rename to src/Masa.Blazor/Components/Form/Validation/ValidationResult.cs diff --git a/src/Masa.Blazor/Components/Form/ValidationResultTypes.cs b/src/Masa.Blazor/Components/Form/Validation/ValidationResultTypes.cs similarity index 100% rename from src/Masa.Blazor/Components/Form/ValidationResultTypes.cs rename to src/Masa.Blazor/Components/Form/Validation/ValidationResultTypes.cs diff --git a/src/Masa.Blazor/Components/Input/IInput.cs b/src/Masa.Blazor/Components/Input/IInput.cs new file mode 100644 index 0000000000..913823c7b6 --- /dev/null +++ b/src/Masa.Blazor/Components/Input/IInput.cs @@ -0,0 +1,6 @@ +namespace Masa.Blazor.Components.Input; + +public interface IInput +{ + void SetFormLabel(string input); +} \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Input/IValidatable.cs b/src/Masa.Blazor/Components/Input/IValidatable.cs index 3e5acc76d3..ac63251b3e 100644 --- a/src/Masa.Blazor/Components/Input/IValidatable.cs +++ b/src/Masa.Blazor/Components/Input/IValidatable.cs @@ -2,7 +2,7 @@ public interface IValidatable { - FieldIdentifier? ValueIdentifier { get; set; } + FieldIdentifier? ValueIdentifier { get; } bool Validate(); diff --git a/src/Masa.Blazor/Components/Input/MInput.Validatable.cs b/src/Masa.Blazor/Components/Input/MInput.Validatable.cs index 70f279ac79..7cef23c15f 100644 --- a/src/Masa.Blazor/Components/Input/MInput.Validatable.cs +++ b/src/Masa.Blazor/Components/Input/MInput.Validatable.cs @@ -66,7 +66,7 @@ protected void UpdateInternalValue(TValue? value, InternalValueChangeType change protected EditContext? OldEditContext { get; set; } - public FieldIdentifier? ValueIdentifier { get; set; } + public FieldIdentifier? ValueIdentifier => ValueExpression is null ? null : FieldIdentifier.Create(ValueExpression); protected bool HasInput { get; set; } @@ -353,22 +353,12 @@ protected virtual void OnInternalValueChange(TValue? val) _ = SetValueByJsInterop(Formatter(val)); } - _ = ValueChanged.InvokeAsync(val.TryDeepClone()); - } + _ = ValueChanged.InvokeAsync(val.TryDeepClone()); + } } - + protected virtual void SubscribeValidationStateChanged() { - if (ValueExpression != null) - { - ValueIdentifier = FieldIdentifier.Create(ValueExpression); - } - else - { - //No ValueExpression,subscribe is unnecessary - return; - } - //When EditContext update,we should re-subscribe OnValidationStateChanged if (OldEditContext != EditContext) { diff --git a/src/Masa.Blazor/Components/Input/MInput.razor b/src/Masa.Blazor/Components/Input/MInput.razor index 8867f662cc..cba8f57884 100644 --- a/src/Masa.Blazor/Components/Input/MInput.razor +++ b/src/Masa.Blazor/Components/Input/MInput.razor @@ -161,7 +161,7 @@ For="@Id" Light="@Light" @onclick:preventDefault="@preventDefaultOnClick"> - @RenderFragments.RenderFragmentOrText(LabelContent, Label, wrapperTag: null) + @RenderFragments.RenderFragmentOrText(LabelContent, ComputedLabel, wrapperTag: null) } }; diff --git a/src/Masa.Blazor/Components/Input/MInput.razor.cs b/src/Masa.Blazor/Components/Input/MInput.razor.cs index 32c9744d84..be633fa372 100644 --- a/src/Masa.Blazor/Components/Input/MInput.razor.cs +++ b/src/Masa.Blazor/Components/Input/MInput.razor.cs @@ -1,8 +1,9 @@ -using StyleBuilder = Masa.Blazor.Core.StyleBuilder; +using Masa.Blazor.Components.Input; +using StyleBuilder = Masa.Blazor.Core.StyleBuilder; namespace Masa.Blazor; -public partial class MInput : MasaComponentBase, IThemeable, IFilterInput +public partial class MInput : MasaComponentBase, IThemeable, IFilterInput, IInput { [Inject] private I18n I18n { get; set; } = null!; @@ -120,7 +121,11 @@ public virtual bool IsDark protected virtual Dictionary InputSlotAttrs { get; set; } = new(); - public virtual bool HasLabel => LabelContent != null || Label != null; + public virtual bool HasLabel => LabelContent != null || ComputedLabel != null; + + public string? ComputedLabel => Label ?? _formLabel; + + private string? _formLabel; public virtual bool HasDetails => MessagesToDisplay.Count > 0; @@ -326,4 +331,9 @@ public void ResetFilter() { Reset(); } + + public void SetFormLabel(string input) + { + _formLabel = input; + } } \ No newline at end of file diff --git a/src/Masa.Blazor/Components/Radio/MRadioGroup.razor b/src/Masa.Blazor/Components/Radio/MRadioGroup.razor index 9a7397a203..683e4d32c6 100644 --- a/src/Masa.Blazor/Components/Radio/MRadioGroup.razor +++ b/src/Masa.Blazor/Components/Radio/MRadioGroup.razor @@ -31,7 +31,7 @@ Id="@Id" Tag="legend" @onclick:preventDefault="@preventDefaultOnClick"> - @RenderFragments.RenderFragmentOrText(LabelContent, Label, wrapperTag: null) + @RenderFragments.RenderFragmentOrText(LabelContent, ComputedLabel, wrapperTag: null) } }; diff --git a/src/Masa.Blazor/Components/TextField/MTextField.razor b/src/Masa.Blazor/Components/TextField/MTextField.razor index 229d09bccc..a2852940c6 100644 --- a/src/Masa.Blazor/Components/TextField/MTextField.razor +++ b/src/Masa.Blazor/Components/TextField/MTextField.razor @@ -39,7 +39,7 @@ Value="@LabelValue" @ref="@LabelReference" @onclick:preventDefault="@preventDefaultOnClick"> - @RenderFragments.RenderFragmentOrText(LabelContent, Label, wrapperTag: null) + @RenderFragments.RenderFragmentOrText(LabelContent, ComputedLabel, wrapperTag: null) } }; diff --git a/src/Masa.Blazor/Components/TextField/MTextField.razor.cs b/src/Masa.Blazor/Components/TextField/MTextField.razor.cs index 6284f981bf..4b32fd6ba1 100644 --- a/src/Masa.Blazor/Components/TextField/MTextField.razor.cs +++ b/src/Masa.Blazor/Components/TextField/MTextField.razor.cs @@ -386,7 +386,7 @@ protected override void RegisterWatchers(PropertyWatcher watcher) base.RegisterWatchers(watcher); watcher.Watch(nameof(Outlined), SetLabelWidthAsync) - .Watch(nameof(Label), () => NextTick(SetLabelWidthAsync)) + .Watch(nameof(Label), () => NextTick(SetLabelWidthAsync)) //TODO:form auto label .Watch(nameof(Prefix), SetPrefixWidthAsync); }