commit a9e785e2bc4d0b1a4a8baeec6ed8b9b3bd970809 Author: Drew C Date: Sun Feb 22 10:30:42 2026 -0600 Initial project commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c702b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,289 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# VS Code +.vscode/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0eaaece --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + 10.0.* + Drew Cavanaugh + + Radzen FluentValidation + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2f953c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 LoreSoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d62a50 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Radzen.FluentValidation + +A small Blazor integration that connects FluentValidation with Radzen forms. It provides a Radzen-compatible validator component (RadzenFluentValidator) that attaches to a RadzenTemplateForm, uses FluentValidation validators (sync or async), and displays validation messages in Radzen style (inline or as tooltip popups). + +## Features +- Integrates FluentValidation with RadzenTemplateForm. +- Per-field validation and message display. +- Supports synchronous and asynchronous validation. +- Optional tooltip popup display with configurable position. +- Supports custom IValidator and IValidatorSelector injection. + +## Installation +- Add the project to your solution or reference the compiled assembly. +- Ensure you have Radzen.Blazor and FluentValidation referenced in your Blazor project. + +Register FluentValidation validators in DI (example in Startup.cs / Program.cs): +```cs +// Register your validators +services.AddTransient, MyModelValidator>(); +``` + +## Usage + +Example model and validator: +```cs +public class MyModel +{ + public string Name { get; set; } +} + +public class MyModelValidator : AbstractValidator +{ + public MyModelValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required."); + } +} +``` + +Example Blazor (Razor) usage with RadzenTemplateForm: +```razor +@using Radzen.Blazor +@using Radzen.FluentValidation + + + + + + @* Name field *@ + + + + + + + + + + + +@code { + private MyModel model = new MyModel(); + + void OnSubmit() + { + // form submit logic + } +} +``` + +Notes: +- The `Component` parameter should match how you identify/find the Radzen field within the form (the component name used by your IRadzenForm implementation). +- Use `AsyncMode=true` to run validators via `ValidateAsync`. +- You can set `Validator` explicitly or let the component resolve an `IValidator` from DI. +- `Selector` can be provided to control which FluentValidation rules run. + +## Behavior +- The component attaches to the form's EditContext and uses a ValidationMessageStore to publish FluentValidation errors to Blazor's validation system. +- Shows combined messages for a field and updates when validation state changes. + +## Contributing +Contributions are welcome. Open issues or PRs with improvements or bug fixes. + +## License +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/Radzen.slnx b/Radzen.slnx new file mode 100644 index 0000000..8f7ec13 --- /dev/null +++ b/Radzen.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/Radzen.FluentValidation/Radzen.FluentValidation.csproj b/src/Radzen.FluentValidation/Radzen.FluentValidation.csproj new file mode 100644 index 0000000..53d987b --- /dev/null +++ b/src/Radzen.FluentValidation/Radzen.FluentValidation.csproj @@ -0,0 +1,13 @@ + + + Radzen.FluentValidation + Radzen.FluentValidation + + + + + + + + + \ No newline at end of file diff --git a/src/Radzen.FluentValidation/RadzenFluentValidator.cs b/src/Radzen.FluentValidation/RadzenFluentValidator.cs new file mode 100644 index 0000000..2548618 --- /dev/null +++ b/src/Radzen.FluentValidation/RadzenFluentValidator.cs @@ -0,0 +1,343 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Text; +using Blazilla; +using FluentValidation; +using FluentValidation.Internal; +using FluentValidation.Results; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using Radzen.Blazor; + +namespace Radzen.FluentValidation; + +/// +/// A Blazor component that integrates FluentValidation with Radzen forms. +/// It validates a specific form field and displays error messages accordingly. +/// This component can only be used inside of a +/// +public class RadzenFluentValidator : RadzenComponent, IRadzenFormValidator +{ + internal const string PendingTask = "_RadzenFluentValidator_PendingTask"; + private static readonly ConcurrentDictionary _validatorTypeCache = new(); + private static readonly ConcurrentDictionary> _contextFactoryCache = new(); + + #region Parameters + + /// + /// Indicates whether to perform validation asynchronously. + /// If , validation will be performed using the ValidateAsync method of the validator; otherwise, it will use the synchronous Validate method. + /// + [Parameter] public bool AsyncMode { get; set; } + /// + /// The name of the form field to validate. + /// This should correspond to the FieldIdentifier of the component being validated. + /// + [Parameter] public string Component { get; set; } = string.Empty; + /// + /// Indicates whether the validation message should be displayed as a popup tooltip. + /// If , the message will be shown in a tooltip; otherwise, it will be displayed inline. + /// + [Parameter] public bool IsPopup { get; set; } + /// + /// Specifies the position of the tooltip when IsPopup is . + /// + [Parameter] public TooltipPosition Position { get; set; } = TooltipPosition.Bottom; + /// + /// The FluentValidation validator instance to use for validating the form field. + /// If not provided, the component will attempt to resolve a validator for the model type from the dependency injection container. + /// + [Parameter] public IValidator? Validator { get; set; } + /// + /// An optional FluentValidation selector that determines which validation rules to execute. + /// + [Parameter] public IValidatorSelector? Selector { get; set; } + [CascadingParameter] private IRadzenForm Form { get; set; } = default!; + [CascadingParameter] private EditContext EditContext { get; set; } = default!; + [Inject] private IServiceProvider ServiceProvider { get; set; } = default!; + + #endregion + + private ValidationMessageStore? _messageStore; + private IValidator? _validator; + private Type _modelType = default!; + + public bool IsValid { get; private set; } = true; + public string Text { get; set; } = string.Empty; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Visible && !IsValid) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "style", Style); + builder.AddAttribute(2, "class", GetCssClass()); + builder.AddMultipleAttributes(3, Attributes); + builder.AddContent(4, Text); + builder.CloseElement(); + } + } + + public override async Task SetParametersAsync(ParameterView parameters) + { + await base.SetParametersAsync(parameters); + + if (Visible) + { + if (EditContext is not null && _messageStore is null) + { + _messageStore = new(EditContext); + UnsubscribeEditContextEvents(); + EditContext.OnFieldChanged += OnFieldChanged; + EditContext.OnValidationRequested += OnValidationRequested; + EditContext.OnValidationStateChanged += OnValidationStateChanged; + } + } + else + { + UnregisterEditContext(); + } + } + + protected override void OnInitialized() + { + _modelType = EditContext.Model.GetType(); + _validator = Validator; + + if (_validator is null) + { + var validatorType = _validatorTypeCache.GetOrAdd(_modelType, t => typeof(IValidator<>).MakeGenericType(t)); + + _validator = ServiceProvider.GetService(validatorType) as IValidator; + } + } + + public override void Dispose() + { + base.Dispose(); + UnregisterEditContext(); + } + + protected override string GetComponentCssClass() + { + var builder = new StringBuilder("rz-message rz-messages-error"); + + if (IsPopup) + { + builder.Append(" rz-message-popup"); + builder.Append($" rz-{Enum.GetName(Position)?.ToLowerInvariant()}-tooltip-content"); + } + + return builder.ToString(); + } + + private void UnsubscribeEditContextEvents() + { + EditContext?.OnFieldChanged -= OnFieldChanged; + EditContext?.OnValidationRequested -= OnValidationRequested; + EditContext?.OnValidationStateChanged -= OnValidationStateChanged; + } + + private void UnregisterEditContext() + { + UnsubscribeEditContextEvents(); + + var component = Form.FindComponent(Component); + + if (component?.FieldIdentifier is not null) + { + _messageStore?.Clear(component.FieldIdentifier); + } + + _messageStore = null; + IsValid = true; + } + + private async void OnFieldChanged(object? sender, FieldChangedEventArgs args) + { + if (_validator is null) + { + return; + } + + var component = Form.FindComponent(Component); + + if (component is null || !args.FieldIdentifier.Equals(component.FieldIdentifier)) + { + return; + } + + var context = BuildValidationContext(component.FieldIdentifier.FieldName); + var result = AsyncMode + ? await _validator.ValidateAsync(context).ConfigureAwait(false) + : _validator.Validate(context); + + ApplyValidationResult(result, component.FieldIdentifier); + + _ = InvokeAsync(EditContext.NotifyValidationStateChanged); + } + + private async void OnValidationRequested(object? sender, ValidationRequestedEventArgs args) + { + if (_validator is null) + { + return; + } + + var component = Form.FindComponent(Component); + + if (component is null || component.FieldIdentifier.FieldName is null) + { + return; + } + + var context = BuildValidationContext(component.FieldIdentifier.FieldName); + + if (AsyncMode) + { + var task = _validator.ValidateAsync(context) + .ContinueWith(async validationTask => + { + var result = await validationTask.ConfigureAwait(false); + + ApplyValidationResult(result, component.FieldIdentifier); + }) + .Unwrap(); + + EditContext.Properties[PendingTask] = task; + + await task.ConfigureAwait(false); + } + else + { + var result = _validator.Validate(context); + + ApplyValidationResult(result); + } + + _ = InvokeAsync(EditContext.NotifyValidationStateChanged); + } + + private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs args) + { + Text = string.Empty; + var component = Form.FindComponent(Component); + + if (component is not null) + { + var errors = EditContext.GetValidationMessages(component.FieldIdentifier); + + Text = string.Join(' ', errors); + } + StateHasChanged(); + } + + private IValidationContext? BuildValidationContext(string? fieldName = null) + { + var selector = CreateSelector(fieldName); + var factory = _contextFactoryCache.GetOrAdd(_modelType, CreateContextFactory); + + return factory(EditContext.Model, null, selector); + } + + private IValidatorSelector CreateSelector(string? fieldName) + { + if (Selector is null && fieldName is null) + { + return ValidatorOptions.Global.ValidatorSelectors.DefaultValidatorSelectorFactory(); + } + + if (fieldName is not null) + { + return ValidatorOptions.Global.ValidatorSelectors.MemberNameValidatorSelectorFactory([fieldName]); + } + + var selectors = new List(); + + if (Selector is not null) + { + selectors.Add(Selector); + } + + if (selectors.Count == 0) + { + return ValidatorOptions.Global.ValidatorSelectors.DefaultValidatorSelectorFactory(); + } + + if (selectors.Count == 1) + { + return selectors[0]; + } + + return ValidatorOptions.Global.ValidatorSelectors.CompositeValidatorSelectorFactory(selectors); + } + + private void ApplyValidationResult(ValidationResult? result, FieldIdentifier? fieldIdentifier = null) + { + result ??= new(); + + if (fieldIdentifier is not null) + { + _messageStore?.Clear(fieldIdentifier.Value); + } + else + { + _messageStore?.Clear(); + } + + IsValid = result.IsValid; + + if (IsValid) + { + return; + } + + var model = EditContext.Model; + + foreach (var error in result.Errors) + { + var field = PathResolver.FindField(model, error.PropertyName) + ?? new(model, error.PropertyName); + + if (fieldIdentifier.HasValue && !field.Equals(fieldIdentifier.Value)) + { + continue; + } + + _messageStore?.Add(field, error.ErrorMessage); + } + } + + private static Func CreateContextFactory(Type modelType) + { + var contextType = typeof(ValidationContext<>).MakeGenericType(modelType); + + // Get the constructor that takes (T instance, PropertyChain propertyChain, IValidatorSelector validatorSelector) + var constructor = contextType.GetConstructor([modelType, typeof(PropertyChain), typeof(IValidatorSelector)]) + ?? throw new InvalidOperationException($"Could not find appropriate constructor for {contextType}"); + + // Create expression parameters + var instanceParam = Expression.Parameter(typeof(object), "instance"); + var propertyChainParam = Expression.Parameter(typeof(PropertyChain), "propertyChain"); + var selectorParam = Expression.Parameter(typeof(IValidatorSelector), "selector"); + + // Convert the instance parameter to the correct model type + var typedInstance = Expression.Convert(instanceParam, modelType); + + // Create the constructor call expression + var constructorCall = Expression.New(constructor, typedInstance, propertyChainParam, selectorParam); + + // Convert the result to IValidationContext + var convertedResult = Expression.Convert(constructorCall, typeof(IValidationContext)); + + // Compile the expression into a delegate + var lambda = Expression.Lambda>( + convertedResult, + instanceParam, + propertyChainParam, + selectorParam); + + return lambda.Compile(); + } +} \ No newline at end of file