嵌套 Blazor 组件中的验证

Validation within nested Blazor components

提问人:Killnine 提问时间:11/16/2023 更新时间:11/20/2023 访问量:89

问:

我正在 Blazor (.net 8) 中设计一个组件,其中包含许多子组件。如下图所示:

The CreateBill component, with child Contact and ChargeTerms components

  • CreateBill 组件是主容器
  • Contact 组件有 3 个实例
  • 有一个 ChargeTerms 组件的单一实例

目前,我的应用程序有一个提交按钮(屏幕外),可以一次对整个模型执行验证:

<EditForm EditContext="_editContext" OnValidSubmit="CreateBillOnSubmit">

这样做的缺点是,模型上的任何验证失败都只是一个大列表,并且没有任何来自特定组件的上下文。例如,如果所有联系表单都是空的,则它们都没有任何突出显示,并且所有验证错误都会重复 3 次(见下文)

Duplicate error messages

我不确定嵌套验证的模式应该是什么,因为根对象只有一个,并且子组件(联系人、收费条款)不包含表单,只包含类似的东西等等。<Form><InputText>

是否有建议的模式来显示每个字段的验证错误?

C# Blazor-Server-Side FluentValidation

评论

0赞 Killnine 11/17/2023
我有一个示例项目,它试图在这里做类似的事情:github.com/killnine/ValidationPrototype

答:

2赞 VonC 11/19/2023 #1

对于 Blazor 嵌套组件,可以尝试使用 CascadingParameterCascadingValue 为子组件提供验证上下文。这允许每个组件参与表单验证并根据上下文显示错误。

在每个子组件中定义一个类型。并通过调用 在子组件的方法中注册每个输入的验证。
使用或自定义验证程序来验证子模型。将每个输入绑定到子组件模型的属性。
在父组件上,处理事件以验证所有子组件。
CascadingParameterEditContextOnInitializededitContext.AddValidationMessageStoreDataAnnotationsValidatorOnValidSubmit

在组件中:Contact

@code {
    [CascadingParameter]
    private EditContext ParentEditContext { get; set; }

    protected override void OnInitialized() {
        if (ParentEditContext != null) {
            // Logic to tie this component's validation to the parent EditContext.
        }
    }
}

在组件中:CreateBill

<EditForm EditContext="_editContext" OnValidSubmit="CreateBillOnSubmit">
    <CascadingValue Value="_editContext">
        <Contact />
        <Contact />
        <Contact />
        <ChargeTerms />
    </CascadingValue>
</EditForm>

@code {
    private EditContext _editContext;

    protected override void OnInitialized() {
        _editContext = new EditContext(new CreateBillModel());
    }
    
    private void CreateBillOnSubmit() {
        // Perform the submission logic here
    }
}

请在提交时调用 以触发所有子组件的验证。ValidateEditContext

这将为每个子组件提供单独的验证上下文,同时仍然是整个表单验证的一部分。这应该可以消除重复错误消息的问题,并向用户提供上下文反馈。
另请参阅“ASP.NET Core Blazor 窗体概述”。

评论

0赞 Killnine 11/20/2023
我将其标记为一个答案,因为我认为它包含了我正在寻找的内容的根本详细信息(将 EditContext 级联到儿童)。但是,我还发布了一个链接,指向 Steve Sanderson 使用 FluentValidation 进行嵌套验证的出色解决方案,因为它是该解决方案的逻辑扩展。谢谢!
3赞 Killnine 11/20/2023 #2

我非常感谢@VonC的解决方案。

我还想提供史蒂夫·桑德森(Steve Sanderson,@MS)在这里链接的解决方案,因为它是一个更充实的解决方案。

关键部分是验证类:

using FluentValidation;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using System;

namespace CustomValidationSample
{
    public class FluentValidator<TValidator> : ComponentBase where TValidator: IValidator, new()
    {
        private readonly static char[] separators = new[] { '.', '[' };
        private TValidator validator;

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

        protected override void OnInitialized()
        {
            validator = new TValidator();
            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 void ValidateModel(EditContext editContext, ValidationMessageStore messages)
        {
            var context = new ValidationContext<object>(editContext.Model);
            var validationResult = _validator.Validate(context);
            messages.Clear();
            foreach (var error in validationResult.Errors)
            {
                var fieldIdentifier = ToFieldIdentifier(editContext, error.PropertyName);
                messages.Add(fieldIdentifier, error.ErrorMessage);
            }
            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("]"))
                {
                    // It's an indexer
                    // This code assumes C# conventions (one indexer named Item with one param)
                    nextToken = nextToken.Substring(0, nextToken.Length - 1);
                    var prop = obj.GetType().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;
            }
        }
    }
}

然后将其添加到 Razor 组件中:

<EditForm Model="customer" OnValidSubmit="SaveCustomer">
    <FluentValidator TValidator="CustomerValidator" />

    <h3>Your name</h3>
    <InputText placeholder="First name" @bind-Value="customer.FirstName" />
    <InputText placeholder="Last name" @bind-Value="customer.LastName" />
    <ValidationMessage For="@(() => customer.FirstName)" />
    <ValidationMessage For="@(() => customer.LastName)" />

    <h3>Your address</h3>
    <div>
        <InputText placeholder="Line 1" @bind-Value="customer.Address.Line1" />
        <ValidationMessage For="@(() => customer.Address.Line1)" />
    </div>
    <div>
        <InputText placeholder="City" @bind-Value="customer.Address.City" />
        <ValidationMessage For="@(() => customer.Address.City)" />
    </div>
    <div>
        <InputText placeholder="Postcode" @bind-Value="customer.Address.Postcode" />
        <ValidationMessage For="@(() => customer.Address.Postcode)" />
    </div>

    <h3>
        Payment methods
        [<a href @onclick="AddPaymentMethod">Add new</a>]
    </h3>
    <ValidationMessage For="@(() => customer.PaymentMethods)" />

    @foreach (var paymentMethod in customer.PaymentMethods)
    {
        <p>
            <InputSelect @bind-Value="paymentMethod.MethodType">
                <option value="@PaymentMethod.Type.CreditCard">Credit card</option>
                <option value="@PaymentMethod.Type.HonourSystem">Honour system</option>
            </InputSelect>

            @if (paymentMethod.MethodType == PaymentMethod.Type.CreditCard)
            {
                <InputText placeholder="Card number" @bind-Value="paymentMethod.CardNumber" />
            }
            else if (paymentMethod.MethodType == PaymentMethod.Type.HonourSystem)
            {
                <span>Sure, we trust you to pay us somehow eventually</span>
            }

            <button type="button" @onclick="@(() => customer.PaymentMethods.Remove(paymentMethod))">Remove</button>

            <ValidationMessage For="@(() => paymentMethod.CardNumber)" />
        </p>
    }

    <p><button type="submit">Submit</button></p>
</EditForm>

@code {
    private Customer customer = new Customer();

    void AddPaymentMethod()
    {
        customer.PaymentMethods.Add(new PaymentMethod());
    }

    void SaveCustomer()
    {
        Console.WriteLine("TODO: Actually do something with the valid data");
    }
}

唯一的缺点(对我来说不是问题)是它会在提交时触发,而如果您想在其他点触发验证,Von 的解决方案可能会提供更大的灵活性。我希望这对您有所帮助!

评论

0赞 VonC 11/20/2023
良好的反馈。点赞。