Search code examples
c#blazorfluentvalidation

CssClass not being set on component when using FluentValidation and Blazor


I'm trying to work out how to validate a deeply nested form structure (sections/panels/fields) where the fields themselves are stored as a dictionary. I have managed to fix up the paths and such so that the standard ValidationMessage componet displays a reasonable human readable error, and all of my validations do appear to function, unfortunately the Css Class setting has ceased to function.

When using a simple form layer (unnested Poco) the classes are applied automatically - my SyncFusion controls receive their 'e-error' or 'e-success' classes and change colour accordingly - when using a home made Validator, the colours don't function any more. I also tried setting my own CssClassProvider with EditContext.SetFieldCssClassProvider, and a breakpoint on the GetFieldCssClass function is never hit.

Effectively whilst my calculated FieldIdentifiers work correctly with ValidationMessage, that doesn't lead to any kind of Css Update.

Is there some kind of trigger that needs to be called from my FluentValidation Validator to kick off the CssClass mechanism?

Here's some code - note that the EditForm was in the parent page of PanelLayout and it's Model is of type PanelLayoutData - I didn't want to have to paste all of that.

PanelLayout.razor

    <article class="st-vscroll bg-body pb-5 st-theme-@Theme">

        <PanelLayoutValidator />

        <ValidationSummary />

        @{
            int sectIndex = 0;
            foreach(var panel in Data.panelData) {
                int panIndex = 0;
                while (panIndex < panel.Panels.Count)
                {
                    var pan1 = panel.Panels[panIndex++];
                    <FieldListPanel Title=@pan1.Title DataDictionary=@(pan1.DisplayDictionary) LabelAbove=@true />
                }
            }
        }
    </article>

    @code {
        [CascadingParameter(Name = "Theme")] public string Theme { get; set; }
        [CascadingParameter(Name = "EditMode")] public bool EditMode { get; set; }
        [CascadingParameter] public EditContext EditContext { get; set; }

        [Parameter] public clientmodels.PanelLayoutData Data { get; set; } = null;

        protected override void OnParametersSet()
        {
            sectionRefs = new ElementReference[Data.panelData.Count];
            if (EditContext != null) {
                EditContext.SetFieldCssClassProvider(new _PanelLayoutFieldCssClassProvider());
            }
        }
    }

FieldListPanel.razor

<div class="card rounded-0 border-0">
    @if (!string.IsNullOrEmpty(Title))
    {
        <div class="card-header border-0 mt-3">
            <h3 class="display-6">@Title</h3>
        </div>
    }
    <div class="card-body">
        @if (DataDictionary?.Any() ?? false)
        {
            @foreach (var kv in DataDictionary) {
                <div class="row mb-3">
                    @if (!(LabelAbove && kv.Value?.DisplayName == ""))
                    {
                        <div class=@((LabelAbove ? "col-12" : "col-4"))>
                            @(kv.Value?.DisplayName ?? kv.Key)@if (kv.Value.IsRequired) { <span style="required-value">*</span> }
                        </div>
                    }
                    <div class=@((LabelAbove ? "col-12" : "col-8"))>
                        @if (kv.Value?.Template != null)
                        {
                            @kv.Value?.Template
                        } else
                        {
                            //When there is no template then it's just text - rendering in edit mode will require a text box
                            if (Editable && EditMode && kv.Value.IsEditable)
                            {
                                <SfTextBox Value=@kv.Value.Value ValueChange="@((__v) => updateDictValue(__v, kv.Key))" /><br />
                                <ValidationMessage For="() => kv.Value.Value" />
                            } else
                            {
                                @kv.Value?.Value
                            }
                        }
                    </div>
                </div>
            }
        }
    </div>
</div>

@code {
    [Parameter]
    public string Title { get; set; } = "";

    [Parameter]
    public IDictionary<string, clientmodels.FieldDisplayData> DataDictionary { get; set; } = null;

    [Parameter]
    public bool LabelAbove { get; set; } = false;

    [Parameter] public bool Editable { get; set; } = true;

    [CascadingParameter(Name = "EditMode")] public bool EditMode { get; set; }

    [Parameter]
    public EventCallback OnChanged { get; set; }

    private async Task updateDictValue(ChangedEventArgs e, string key)
    {
        if (DataDictionary.ContainsKey(key))
        {
            DataDictionary[key].Value = e.Value;
            await OnChanged.InvokeAsync();
        }
    }
}

PanelLayoutValidator.cs

    namespace CustomStyle.Client.Code
    {
        public class _PanelLayoutValidationState
        {
            public string FullPath { get; set; }
        }

        public class _PanelLayoutFieldCssClassProvider : FieldCssClassProvider
        {
            public override string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier)
            {
                var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any();

                return isValid ? "e-success" : "e-error";
            }
        }

        public class _PanelLayoutValidator : AbstractValidator<PanelLayoutData>
        {
            public _PanelLayoutValidator()
            {
            RuleForEach(l => l.panelData)
                .ChildRules(l => {
                    l.RuleForEach(s => s.Panels)
                    .ChildRules(s => {

                        //We apply rules for [Required] [MinLength] [MaxLength] [CreditCard] [EmailAddress] [Range] [RegularExpression]

                        //Configure property names
                        var ddeach = s.RuleForEach(p => p.DisplayDictionary);
                        ddeach.ChildRules(kvconfig =>
                        {
                            kvconfig.RuleFor(kv => kv.Value.Value).Configure(cfg =>
                            {
                                cfg.MessageBuilder = context =>
                                {
                                    context.MessageFormatter.AppendPropertyName(context.PropertyName);
                                    return context.GetDefaultMessage();
                                };
                            });
                        });
                        //Non parametric validations
                        ddeach.ChildRules(kvconfig =>
                        {
                            //IsRequired
                            kvconfig.RuleFor(kv => kv.Value.Value)
                            .NotEmpty().When(x => x.Value != null && x.Value.IsRequired).WithMessage("{ParsedPropertyName} cannot be empty");

                            //CreditCard
                            kvconfig.RuleFor(kv => kv.Value.Value)
                            .CreditCard().When(x => x.Value != null && x.Value.IsCreditCard).WithMessage("{ParsedPropertyName} should be a Credit or Debit card number");

                            //EmailAddress
                            kvconfig.RuleFor(kv => kv.Value.Value)
                            .EmailAddress().When(x => x.Value != null && x.Value.IsCreditCard).WithMessage("{ParsedPropertyName} should be an Email Address");
                        });

                        //Parametric validations
                        ddeach.ChildRules(kvconfig =>
                        {
                            //MinLength
                            kvconfig.RuleFor(kv => new { Value = kv.Value.Value, Config = kv.Value })
                            .Must(vl => vl.Value.Length >= vl.Config.MinLength)
                            .When(x => x.Value != null && x.Value.MinLength != null)
                            .WithMessage(x => $"{{ParsedPropertyNameVV}} must have at least {x.Value.MinLength} characters");

                            //MaxLength
                            kvconfig.RuleFor(kv => new { Value = kv.Value.Value, Config = kv.Value })
                            .Must(vl => vl.Value.Length <= vl.Config.MaxLength)
                            .When(x => x.Value != null && x.Value.MaxLength != null)
                            .WithMessage(x => $"{{ParsedPropertyNameVV}} must have at most {x.Value.MaxLength} characters");

                            //Range
                            kvconfig.RuleFor(kv => new { Value = int.Parse(kv.Value.Value), Config = kv.Value })
                            .Must(vl => vl.Value >= vl.Config.Range[0] && vl.Value <= vl.Config.Range[1])
                            .When(x => x.Value != null && x.Value.Range != null)
                            .WithMessage(x => $"{{ParsedPropertyNameVV}} must be between {x.Value.Range[0]} and {x.Value.Range[1]}");

                            //Regex
                            kvconfig.RuleFor(kv => new { Value = kv.Value.Value, Config = kv.Value })
                            .Must(vl => System.Text.RegularExpressions.Regex.IsMatch(vl.Value, vl.Config.RegularExpression))
                            .When(x => x.Value != null && x.Value.RegularExpression != null)
                            .WithMessage(x => $"{{ParsedPropertyNameVV}} does not match the expected pattern");
                        });

                        //ToDo: Add rules for RefPoco routes based on object data annotation attributes 
                    });
                });
            }
        }

        public class PanelLayoutValidator : ComponentBase
        {
            private readonly static char[] separators = new[] { '.', '[' };
            private _PanelLayoutValidator validator;

            [CascadingParameter] private EditContext EditContext { get; set; }

            protected override void OnInitialized()
            {
                validator = new _PanelLayoutValidator();
                var messages = new ValidationMessageStore(EditContext);

                // Revalidate when any field changes, or if the entire form requests validation
                // (e.g., on submit)

                EditContext.OnFieldChanged += (sender, eventArgs)
                => ValidateModel((EditContext)sender, messages);

                EditContext.OnValidationRequested += (sender, eventArgs)
                => ValidateModel((EditContext)sender, messages);
            }

            private string GetParsedPropertyName(EditContext context, FieldIdentifier id, string PropertyName)
            {
                //process the property path to calculate the property description

                //If we're using the expected format for a dictionary field, we can read the display name and the key
                var model = context.Model as PanelLayoutData;
            var match = System.Text.RegularExpressions.Regex.Match(PropertyName, @"^panelData\[(?<section_index>[^\]]+)\].Panels\[(?<panel_index>[^\]]+)\].DisplayDictionary\[(?<field_key>[^\]]+)\].(?<target>Value|RefPoco)");

                if (match.Success)
                {
                    var section_index = int.Parse(match.Groups["section_index"].Value);
                    var section_name = model.panelData[section_index].Title;

                    var panel_index = int.Parse(match.Groups["panel_index"].Value);
                    var panel_name = model.panelData[section_index].Panels[panel_index].Title;

                    var property_name = "";

                    if (match.Groups["target"].Value == "Value")
                    {
                        var field_key_index = int.Parse(match.Groups["field_key"].Value);
                        var dict = model.panelData[section_index].Panels[panel_index].DisplayDictionary;
                        var field_key = dict.Keys.Skip(field_key_index).First();
                        property_name = dict[field_key].DisplayName ?? field_key;
                    } else
                    {
                        //TODO: Expand this to grab the property and look for DisplayName attributes
                        property_name = id.FieldName;
                    }

                    if (!string.IsNullOrEmpty(section_name)) {
                        section_name += "/";
                    }
                    if (!string.IsNullOrEmpty(panel_name))
                    {
                        panel_name += "/";
                    }

                    return $"{section_name}{panel_name}{property_name}";
                } else {
                    //we have no section info so just pick up the property name
                    //TODO: Expand this to grab the property and look for DisplayName attributes
                    return id.FieldName;
                }
            }

            private void ValidateModel(EditContext editContext, ValidationMessageStore messages)
            {
            var validationResult = validator.Validate((PanelLayoutData)editContext.Model);
            messages.Clear();
            foreach (var error in validationResult.Errors)
            {
                FieldIdentifier fieldIdentifier = default(FieldIdentifier);

                var msg = error.ErrorMessage;
                if (msg.Contains("{ParsedPropertyName}")) {
                    fieldIdentifier = ToFieldIdentifier(editContext, error.PropertyName);
                    msg = msg.Replace("{ParsedPropertyName}", GetParsedPropertyName(editContext, fieldIdentifier, error.PropertyName));
                }
                if (msg.Contains("{ParsedPropertyNameVV}"))
                {
                    fieldIdentifier = ToFieldIdentifier(editContext, $"{error.PropertyName}.Value");
                    msg = msg.Replace("{ParsedPropertyNameVV}", GetParsedPropertyName(editContext, fieldIdentifier, $"{error.PropertyName}.Value"));
                }
                messages.Add(fieldIdentifier, msg);
            }
            editContext.NotifyValidationStateChanged();
            }

            private static FieldIdentifier ToFieldIdentifier(EditContext editContext, string propertyPath)
            {
                // This method parses property paths like 'SomeProp.MyCollection[123].ChildProp'
                // and returns a FieldIdentifier which is an (instance, propName) pair. For example,
                // it would return the pair (SomeProp.MyCollection[123], "ChildProp"). It traverses
                // as far into the propertyPath as it can go until it finds any null instance.

                var obj = editContext.Model;

                while (true)
                {
                    var nextTokenEnd = propertyPath.IndexOfAny(separators);
                    if (nextTokenEnd < 0)
                    {
                        return new FieldIdentifier(obj, propertyPath);
                    }

                    var nextToken = propertyPath.Substring(0, nextTokenEnd);
                    propertyPath = propertyPath.Substring(nextTokenEnd + 1);

                    object newObj;
                    if (nextToken.EndsWith("]"))
                    {
                        nextToken = nextToken.Substring(0, nextToken.Length - 1);

                        var tobj = obj.GetType();
                        if (obj is IDictionary)
                        {
                            //fluent validation indicates index in dictionary as an integer - dictionaries don't index like that
                            //grab the key at the given index
                            var kprop = tobj.GetProperty("Keys");
                            var keys = (ICollection)kprop.GetValue(obj);
                            object key = keys.Cast<object>().Skip(int.Parse(nextToken)).First();
                            var prop = tobj.GetProperty("Item");
                            newObj = prop.GetValue(obj, new object[] { key });
                        }
                        else
                        {
                            // It's an indexer
                            // This code assumes C# conventions (one indexer named Item with one param)
                            var prop = tobj.GetProperty("Item");
                            var indexerType = prop.GetIndexParameters()[0].ParameterType;
                            var indexerValue = Convert.ChangeType(nextToken, indexerType);
                            newObj = prop.GetValue(obj, new object[] { indexerValue });
                        }
                    }
                    else
                    {
                        // It's a regular property
                        var prop = obj.GetType().GetProperty(nextToken);
                        if (prop == null)
                        {
                            throw new InvalidOperationException($"Could not find property named {nextToken} on object of type {obj.GetType().FullName}.");
                        }
                        newObj = prop.GetValue(obj);
                    }

                    if (newObj == null)
                    {
                        // This is as far as we can go
                        return new FieldIdentifier(obj, nextToken);
                    }

                    obj = newObj;
                }
            }
        }
    }

PanelModels.cs

namespace CustomStyle.Client.Models
{
    public class PanelData
    {
        public string Title { get; set; }
        public Dictionary<string, FieldDisplayData> DisplayDictionary { get; set; } = new();
        public bool IsFullWidth { get; set; } = false;
        public bool IsSpacer { get; set; } = false;
        public bool LabelAbove { get; set; } = false;
    }
    public class PanelSectionData
    {
        [Required]
        public string Title { get; set; }
        public List<PanelData> Panels { get; set; } = new();
    }
    public class PanelLayoutData : PageLayoutData
    {
        public string idPrefix { get; set; }
        public List<PanelSectionData> panelData { get; set; } = new();
    }

    public class FieldDisplayData
    {
        public FieldDisplayData()
        {
        }

        public FieldDisplayData(string value, bool isRequired = false)
        {
            DisplayName = null;
            Value = value;
            Template = null;
            IsRequired = isRequired;
        }

        public FieldDisplayData(RenderFragment template, bool isRequired = false)
        {
            DisplayName = null;
            Value = null;
            Template = template;
            IsRequired = isRequired;
        }

        public FieldDisplayData(string displayName, string value, bool isRequired = false)
        {
            DisplayName = displayName;
            Value = value;
            Template = null;
            IsRequired = isRequired;
        }

        public FieldDisplayData(string displayName, RenderFragment template, bool isRequired = false)
        {
            DisplayName = displayName;
            Value = null;
            Template = template;
            IsRequired = isRequired;
        }

        public string DisplayName { get; set; } = null;
        public string Value { get; set; } = null;
        public RenderFragment Template { get; set; } = null;
        //If the Template references a child of the overall panel data tree,
        //add a reference here to allow the validator to see it
        public object RefPoco { get; set; } = null;

        public bool IsEditable { get; set; } = true;
        public bool IsRequired { get; set; } = false;
        public int? MinLength { get; set; } = null;
        public int? MaxLength { get; set; } = null;
    }
}

Hopefully somebody might just know what I messed up.

Thanks.


Solution

  • This is how things work in the standard Blazor Input Controls, which is probably similar to the SynFusion controls (but as they are proprietary the jury is out).

    The input control builds a FieldIdentifier from the ValueExpression that either you provide manually or the Razor compiler builds for you from a @bind-Value definition. When a component renders, it uses this FieldIdentifier to check for validation messages in the Validation Store and then applies the neccessary Css settings through a FieldCssClassProvider.

    So either:

    1. Your input components aren't re-rendering when the validation state changes in the form, or
    2. The FieldIdentifier constructed by the input control doesn't match the one used to identify the validation message in the Validation Store, or
    3. The Syncfusion controls operate to a different set of rules.

    As I don't use Syncfusion and you're question is a bit of a wall of code without context I can only offer pointers as to where the problem might be. Hopefully someone with Syncfusion knowledge will provide a more solid answer. Good luck.