Home > Software design >  Blazor: ValidationMessage won't show after manually filling ValidationMessageStore
Blazor: ValidationMessage won't show after manually filling ValidationMessageStore

Time:10-31

I'm trying to :

  1. Upload an Excel to the controller from a Blazor page
  2. Process the rows in the controller and perform some stuff with them (and return empty response if successful)
  3. If there were validation errors while parsing the Excel, return them to the page

I don't know how many rows there will be beforehand, so I'm returning that number too from the controller. I don't have a model either, so I'll just fake it in Blazor to display the validation errors. The ValidationMessages won't show. Here's a test code, without need of a server invocation:

@using System.Collections
<button @onclick="GenerateErrors">
    Generate
</button>
<EditForm EditContext="@editContext">
    @if (Errors != null)
    {
        for (int i = 0; i < Errors.Rows; i  )
        {
            <ValidationMessage For="()=>cmd.Users[i].Name" />
        }
    }
</EditForm>
@code {
    private record FakeValidationError(string Identifier, string ErrorMessage);
    // This is what I would get from the controller
    private record FakeValidationErrors(List<FakeValidationError> Errors, int? Rows); 
    private class FakeList<T> : IList<T> where T : new()
    {
        public T this[int index] { get => _factory == null ? new T() : _factory(); set => _ = value; }
        private Func<T> _factory;
        public int Count => throw new NotImplementedException();
        public FakeList(Func<T> factory = null)
        {
            _factory = factory;
        }
        public bool IsReadOnly => false;
        public void Add(T item) => throw new NotImplementedException();
        public void Clear() => throw new NotImplementedException();
        public bool Contains(T item) => throw new NotImplementedException();
        public void CopyTo(T[] array, int arrayIndex) => throw new NotImplementedException();
        public IEnumerator<T> GetEnumerator() { yield return _factory == null ? new T() : _factory(); }
        public int IndexOf(T item) => throw new NotImplementedException();
        public void Insert(int index, T item) => throw new NotImplementedException();
        public bool Remove(T item) => throw new NotImplementedException();
        public void RemoveAt(int index) => throw new NotImplementedException();
        IEnumerator IEnumerable.GetEnumerator() { yield return _factory == null ? new T() : _factory(); }
    }
    private class FakeUser
    {
        public string Name { get; set; }
    }
    private class FakeUsers
    {
        public IList<FakeUser> Users { get; set; } = new FakeList<FakeUser>();
    }
    // This is the model, which I have to fake, because it only lives in the controller when it parses the Excel
    // It's not returned by it because it won't validate, so it's meaningless.
    FakeUsers cmd = new();
    protected override async Task OnInitializedAsync()
    {
        editContext = new(cmd);
        messageStore = new(editContext);
        base.OnInitialized();
    }
    FakeValidationErrors Errors = null;
    protected EditContext editContext;
    protected ValidationMessageStore messageStore;
    private void GenerateErrors()
    {
        List<FakeValidationError> l = new();
        l.Add(new("Users[5].Name", "Bad name at #5"));
        Errors = new(l, 8);
        messageStore?.Clear();
        foreach (var x in Errors.Errors)
        {
            FieldIdentifier f = new FieldIdentifier(cmd, x.Identifier);
            messageStore.Add(f, x.ErrorMessage);
        }
        editContext.NotifyValidationStateChanged();
    }
}

I know there are a lot of pragmatic ways to achieve this. But I want to know, specifically, why this won't work. This piece of code

        messageStore?.Clear();
        foreach (var x in Errors.Errors)
        {
            FieldIdentifier f = new FieldIdentifier(cmd, x.Identifier);
            messageStore.Add(f, x.ErrorMessage);
        }
        editContext.NotifyValidationStateChanged();

works all the time in other EditForms.

UPDATE: There was a typo in the code. It's been deleted(Errors=new())

ValidationSummary works

I've tried with a variable so the lambda won't capture i. It makes no difference

for (int i = 0; i < Errors.Rows; i  )
        {
            var j=i;
            <ValidationMessage For="()=>cmd.Users[j].Name" />
        }

CodePudding user response:

ValidatationMessage needs a FieldIdentifier to do it's lookup in the message store. It gets one by passing the Expression defined in For to the Create method on FieldIdentifier. Decoding the Expression is done by an internal method called ParseAccessor.

It can't decode ()=>cmd.Users[j].Name so it doesn't know what to display. I've included the relevant code from FieldIdentifier at the bottom of this answer for reference.

One solution is to write your own ValidationMessage which directly accepts a FieldIdfentifier as For. Here is a barebones version:

public class MyValidationMessage : ComponentBase, IDisposable
{
    [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
    [CascadingParameter] EditContext CurrentEditContext { get; set; } = default!;
    [Parameter, EditorRequired] public FieldIdentifier For { get; set; } = new FieldIdentifier();

    protected override void OnParametersSet()
    {
        if (CurrentEditContext == null)
            throw new InvalidOperationException($"{GetType()} requires a cascading parameter ");

        CurrentEditContext.OnValidationStateChanged  = OnValidationStateChanged;
    }

    private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
        => StateHasChanged();

    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        foreach (var message in CurrentEditContext.GetValidationMessages(For))
        {
            builder.OpenElement(0, "div");
            builder.AddAttribute(1, "class", "validation-message");
            builder.AddMultipleAttributes(2, AdditionalAttributes);
            builder.AddContent(3, message);
            builder.CloseElement();
        }
    }

    public void Dispose()
    {
        if (CurrentEditContext is not null)
            CurrentEditContext.OnValidationStateChanged  = OnValidationStateChanged;
    }
}

Here's a classic form demo page:

@page "/"
@using System.ComponentModel.DataAnnotations

<PageTitle>Index</PageTitle>

<EditForm EditContext=this.editContext >
    <DataAnnotationsValidator />
    <div >Enter more than two chars:</div>
    <InputText  @bind-Value=model.Name />
    <MyValidationMessage For="@(new FieldIdentifier(model, "Name"))" />
    <div >
        <button type="submit" >Submit</button>
    </div>
</EditForm>

@code {
    private ModelData model = new ModelData();
    private ValidationMessageStore? myStore;
    private EditContext? editContext;

    public class ModelData {
        [Required]
        [StringLength(2, ErrorMessage = "Name is too long.")]
        public string? Name { get; set; }
    }

    protected override void OnInitialized()
    {
        editContext = new EditContext(model);
        myStore = new ValidationMessageStore(editContext);
    }
}

And a simplified version of yours. Notice that the EditContext points to a different simple object. It's only being used for the ValidationMessageStore and events!

@page "/"
@using System.ComponentModel.DataAnnotations

<PageTitle>Index</PageTitle>

<EditForm EditContext=this.editContext >
    @foreach (var item in models)
    {
        <MyValidationMessage For="@(new FieldIdentifier(item, "Value"))" />
    }
</EditForm>

<div >
    <button  @onclick="LogMessages">Create Errors</button>
    <button  @onclick="() => LogMessage(0)">Error on Row 0</button>
    <button  @onclick="() => LogMessage(1)">Error on Row 1</button>
    <button  @onclick="() => LogMessage(2)">Error on Row 2</button>
</div>

@code {
    private RowData model = new RowData();
    private List<RowData> models = new() {
        new RowData {Value=1},
        new RowData {Value=2},
        new RowData {Value=3},
    };

    private ValidationMessageStore? myStore;
    private EditContext? editContext;

    public class RowData
    {
        public int Value { get; set; }
    }

    protected override void OnInitialized()
    {
        editContext = new EditContext(model);
        myStore = new ValidationMessageStore(editContext);
    }

    private void LogMessage(int index)
    {
        this.myStore?.Clear();
        this.myStore?.Add(new FieldIdentifier(models[index], "Value"), $"Row {index} error");
        this.editContext?.NotifyValidationStateChanged();
    }

    private void LogMessages()
    {
        this.myStore?.Clear();
        this.myStore?.Add(new FieldIdentifier(models[0], "Value"), $"Row {0} error");
        this.myStore?.Add(new FieldIdentifier(models[2], "Value"), $"Row {2} error");
        this.editContext?.NotifyValidationStateChanged();
    }
}

For Reference

Here's the relevant FieldIdentifier code.


    public static FieldIdentifier Create<TField>(Expression<Func<TField>> accessor)
    {
        if (accessor == null)
        {
            throw new ArgumentNullException(nameof(accessor));
        }

        ParseAccessor(accessor, out var model, out var fieldName);
        return new FieldIdentifier(model, fieldName);
    }

    private static void ParseAccessor<T>(Expression<Func<T>> accessor, out object model, out string fieldName)
    {
        var accessorBody = accessor.Body;

        // Unwrap casts to object
        if (accessorBody is UnaryExpression unaryExpression
            && unaryExpression.NodeType == ExpressionType.Convert
            && unaryExpression.Type == typeof(object))
        {
            accessorBody = unaryExpression.Operand;
        }

        if (!(accessorBody is MemberExpression memberExpression))
        {
            throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.");
        }

        // Identify the field name. We don't mind whether it's a property or field, or even something else.
        fieldName = memberExpression.Member.Name;

        // Get a reference to the model object
        // i.e., given a value like "(something).MemberName", determine the runtime value of "(something)",
        if (memberExpression.Expression is ConstantExpression constantExpression)
        {
            if (constantExpression.Value is null)
            {
                throw new ArgumentException("The provided expression must evaluate to a non-null value.");
            }
            model = constantExpression.Value;
        }
        else if (memberExpression.Expression != null)
        {
            // It would be great to cache this somehow, but it's unclear there's a reasonable way to do
            // so, given that it embeds captured values such as "this". We could consider special-casing
            // for "() => something.Member" and building a cache keyed by "something.GetType()" with values
            // of type Func<object, object> so we can cheaply map from "something" to "something.Member".
            var modelLambda = Expression.Lambda(memberExpression.Expression);
            var modelLambdaCompiled = (Func<object?>)modelLambda.Compile();
            var result = modelLambdaCompiled();
            if (result is null)
            {
                throw new ArgumentException("The provided expression must evaluate to a non-null value.");
            }
            model = result;
        }
        else
        {
            throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.");
        }
    }

CodePudding user response:

I think the error is related to this: "Users[5].Name"

The field name is certainly not "Users[5].Name"

public void DisplayErrors(Dictionary<string, List<string>> errors)
        {
            foreach (var err in errors)
            {
                _messageStore.Add(CurrentEditContext.Field(err.Key), err.Value);
            }
            CurrentEditContext.NotifyValidationStateChanged();
        }
  • Related