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.
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:
FieldIdentifier
constructed by the input control doesn't match the one used to identify the validation message in the Validation Store, orAs 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.