Megabit / Blazorise

Blazorise is a component library built on top of Blazor with support for CSS frameworks like Bootstrap, Tailwind, Bulma, AntDesign, and Material.
https://blazorise.com/
Other
3.28k stars 530 forks source link

Cant find a way to insert custom validation messages from API response to validator #1368

Open JasonRodman opened 4 years ago

JasonRodman commented 4 years ago

I am currently using data annotations validations for my models, and that is working beautifully on the client. I also enforce validation on the server side in the API once my models are pass off to business logic, and I am trying to bridge the gap between the two. My business logic raises exceptions when a validation rule is broken which I am returning in the standardized ValidationProblemDetails from the API. I am trying to find a way to get those messages back into the your validation system to they can be shown on the fields they relate to. My business logic does not trust anything from the outside so it has its own validation, even though it may repeat some of the UI validation in the models. This is done purely to improve the user experience. I need a way to get that feedback fed into the UI the same way data annotations are handled on the client. I want the user's experience to be seamless. I was hoping there was some method on the component that would allow me to add custom feedback for a field. Any ideas how this can be accomplished? Below is an example of submitting a form to the API and back and reading the errors back. This is where I would need to pass them back to the validation system.

@code { [Parameter] public int? Id { get; set; }

private ProductModel _model = new ProductModel();
private Validations _validations; // <- instance of validations component for the form

private async Task OnSubmitAsync()
{
    try
    {
        _saving = true;

        var response = Id.HasValue
            ? await Http.PutAsJsonAsync($"api/products/{Id.Value}", _model)
            : await Http.PostAsJsonAsync($"api/products", _model);

        if (response.IsSuccessStatusCode)
            NavigationManager.NavigateTo("/products");

        switch (response.StatusCode)
        {
            case HttpStatusCode.BadRequest:
                var validationProblems = await response.Content.ReadFromJsonAsync<ValidationProblemDetailsModel>();
                // ?? how do I get this back into the validation of the form?
                break;
            case HttpStatusCode.Unauthorized:
                var problems = await response.Content.ReadFromJsonAsync<ProblemDetailsModel>();
                // ?? how do I get this back into the validation of the form?
                break;
        }

    }
    catch (Exception e)
    {
        Logger.LogError("Form processing error: {MESSAGE}", e.Message);
    }
}

}

stsrki commented 4 years ago

In the last preview, I enabled setting the Status on a Validation component. In this way, you might be able to define the initial state of validation. eg.

<Validation Status="@resultFromYourApi">
   ..
</Validation>

It's the closest to your scenario I can think of.

JasonRodman commented 4 years ago

@stsrki That seems useful to light a field red, but I actually need a way to insert a message into the feedback for a field. Maybe I could propose a new feature that allows you to specify a validation error for specific field by name? Something along the lines of this?

Validations.AddModelError("FirstName", "Some validation message...");

This would look similar to the AddModelError method on the ModelState class which exists for this very reason.

Francescolis commented 3 years ago

Have you finally found a solution to this problem?

stsrki commented 3 years ago

You could always inherit from the Validations or Validation component and implement the AddModelError.

stsrki commented 3 years ago

@JasonRodman

It's probably too late by now. But I think you can do this with the ValidationSummary component. https://blazorise.com/docs/components/validation/#validation-summary

You can inject custom messages by using the Errors parameter

<ValidationSummary Errors="@stringArrayOfMessages" />
Francescolis commented 3 years ago

Thank you for your answer. ValidationSummary is not what I want to do. But I found another way to be able to do it more elegant, inspired by the code provided by Microsoft, by deriving from DataAnnotationsValidator component, I can add custom errors in the context.

stsrki commented 3 years ago

Cool. Any chance you could share some parts of your code?

Edit* we might add it as part of community articles.

Francescolis commented 3 years ago

Of course! That's why we're here.

/// <summary>
/// Adds <see cref="IOperationResult"/> Data Annotations validation support to an <see cref="EditContext"/>.
/// </summary>
public class DataAnnotationsValidatorExtended : DataAnnotationsValidator
{
    private ValidationMessageStore _validationMessageStore = default!;

    [CascadingParameter]
    private EditContext AnnotationEditContext { get; set; } = default!;

    ///<inheritdoc/>
    protected override void OnInitialized()
    {
        base.OnInitialized();

        _validationMessageStore = new(AnnotationEditContext);
        AnnotationEditContext.OnValidationRequested += (s, e) => _validationMessageStore.Clear();
        AnnotationEditContext.OnFieldChanged += (s, e) => _validationMessageStore.Clear(e.FieldIdentifier);
    }

    /// <summary>
    /// Applies <see cref="IOperationResult"/> errors to the context.
    /// </summary>
    /// <param name="result">The operation result to act with.</param>
    /// <remarks>Only available for failed result.</remarks>
    /// <exception cref="ArgumentNullException">The <paramref name="result"/> is null.</exception>
    public virtual void ValidateModel(IOperationResult result)
    {
        _ = result ?? throw new ArgumentNullException(nameof(result));

        if (result.Succeeded)
            return;

        foreach (var error in result.Errors)
        {
            _validationMessageStore.Add(AnnotationEditContext.Field(error.Key), error.ErrorMessages);
        }

        AnnotationEditContext.NotifyValidationStateChanged();
    }

    /// <summary>
    /// Removes the notifications from the context and notify changes.
    /// </summary>
    public virtual void ClearModel()
    {
        _validationMessageStore.Clear();
        AnnotationEditContext.NotifyValidationStateChanged();
    }
}

IOperationResult represents the status of an operation. The result contains "Succeeded" and "Failed" which determines operation exit state, "StatusCode" that returns the HTTP status code and "Errors" which shows errors for failing operation execution.

You can replace IOperationResult with IDictionary<string, string[]> :

public virtual void ValidateModel(IDictionary<string, string[]> errors)
{
    _ = errors ?? throw new ArgumentNullException(nameof(errors));

    foreach (var error in errors)
    {
        _validationMessageStore.Add(AnnotationEditContext.Field(error.Key), error.Value);
    }

    AnnotationEditContext.NotifyValidationStateChanged();
}

And in your razor component SignIn.razor :


<EditForm Model="@model" OnValidSubmit="SignInSubmitAsync">

    <DataAnnotationsValidatorExtended @ref="@Validator" />

    <div class="form-group">
        <label for="Email" class="col-md-12 col-form-label">Email</label>
        <div class="col-md-12">
            <InputTextOnInput @bind-Value="model.Email" type="email" class="form-control" />
            <ValidationMessage For="@(() => model.Email)" />
        </div>
    </div>

...
</EditForm>

InputTextOnInput.razor An input component for editing "string" values on input key and also use the css style is-valid and is-invalid.

In the code behind :

    public partial class SignIn
    {
        private readonly SignInModel model = new();
        ...

        protected DataAnnotationsValidatorExtended Validator { get; set; } = default!;
        [Inject] // For demo
        protected IAuthenticationService AuthenticationService { get; set; } = default!;

        protected async Task SignInSubmitAsync()
        {
            ...

            var authResponse = await AuthenticationService.SignInAsync(...);

            // AuthenticationService returns an IOperationResult
            // You can use ValidationProblemDetails as specified in the first message.

            if (authResponse.Failed)
            { 
                // Add errors to the context and notifies the component that its state has changed.
                Validator.ValidateModel(authResponse); 
             }

            ...
        }

    }
stsrki commented 3 years ago

Excellent work. Thank you!

pfranceschi25 commented 3 years ago

I was just looking for how to display web api validation error results in a Blazorise form. Looks like the code shared is not meant to work with Blazorise, but with the raw Blazor form component. Any chance this could be worked into Blazorise form validation component?

simelis commented 2 years ago

You could always inherit from the Validations or Validation component and implement the AddModelError.

Hi, I have the same problem, I managed to resolve it by adding this code to Validations.razor.cs:


public void ProcessErrors( IDictionary<string, string[]> errors )
        {
            foreach ( var error in errors )
            {
                var validator = this.validations.Where( x => x.FieldIdentifier.FieldName == error.Key ).FirstOrDefault();

                if ( validator != null )
                {
                    validator.NotifyValidationStatusChanged( ValidationStatus.Error, error.Value );
                }
                else
                {
                    RaiseStatusChanged( ValidationStatus.Error, error.Value );
                }
            }
        }

The problem is I cannot use inherited component, as lots of members are private or internal, so I had to actually include the whole modified blazorise into my solution to make it work. How would you suggest to resolve this issue?

stsrki commented 2 years ago

@simelis You can register your custom component with a Dependency Injection and it will be picked by the framework

eg.

serviceCollection.AddTransient( typeof( Blazorise.Validation ), typeof( YourCustomValidation ) );

simelis commented 2 years ago

@stsrki Unfortunatelly, I cannot get it to work. Are you sure the registration would ensure MyCustomValidations was passed with [CascadingParameter]? I'm concerned about this line in Validation.razor.cs: [CascadingParameter] public Validations ParentValidations { get; protected set; }

stsrki commented 2 years ago

Yes, it should be picked if MyCustomValidations was inherited from the Validations.

Nex-Code commented 2 years ago

@simelis

I've used this to some success in my code. Would be a little easier if the validation list was visible on the Validations, but eh.

You also have to override the dependency injections for Blazorise.

builder.Services.AddTransient<Validations, ApiExtendedValidations>();
builder.Services.AddTransient<Validation, ApiExtendedValidation>();
public class ApiExtendedValidations : Validations
    {

        public void AddMessages(string memberName, IEnumerable<string> errors)
        {
            var val = ValidationFields.FirstOrDefault(i =>
                i.FieldIdentifier.FieldName.Equals(memberName, StringComparison.CurrentCultureIgnoreCase));

            if (val == null)
                return;

            var messages = val.Messages?.ToList()??new List<string>();
            messages.AddRange(errors);
            val.NotifyValidationStatusChanged(ValidationStatus.Error, messages);
        }

        private List<ApiExtendedValidation> ValidationFields { get; set; } = new List<ApiExtendedValidation>();

        public void AddValidation(ApiExtendedValidation field)
        {
            ValidationFields.Add(field);
        }

        public void RemoveValidation(ApiExtendedValidation field)
        {
            ValidationFields.Remove(field);
        }

    }

    public class ApiExtendedValidation : Validation, IDisposable
    {

        protected override Task OnInitializedAsync()
        {

            if (ParentValidations is ApiExtendedValidations validations)
            {
                validations.AddValidation(this);
            }

            return base.OnInitializedAsync();
        }

        public new void Dispose()
        {
            if (ParentValidations is ApiExtendedValidations validations)
            {
                validations.RemoveValidation(this);
            }

            base.Dispose();
        }

    }

    public static class ValidationsHelper
    {
                public static void AddMessages(this Validations validations, string memberName, params string[] messages)
        {

            if (!(messages?.Any()??false))
                return;

            if (validations is ApiExtendedValidations val)
            {
                val.AddMessages(memberName, messages);
            }
        }
    }

The extensions are mostly so I didn't have to go back and change all my existing Validations to reference the correct thing.