Serverside Blazor InputText - asynchronous validation of username / email address in account registration form

Viewed 63

Context

I have a simple Blazor server side form, which I use for account registration / sign up on a public web site.

The user is asked to input first name, last name, email, username, and password. I use Data Annotations to validate simple constraints such as MinLength, MaxLength, etc, which works fine.

The problem

I'm struggling to find a way of asynchronously validating that the username and email doesn't already exist.For instance, after the user has entered their email address and tabbed to the next field, I would like to trigger an asynchronous process to check if an account with such an email already exists and then display a validation error using the exisiting data validation summary component to avoid writing too much custom logic. If the account username or email address is already taken, it should block the user from going any further and provide an easy-to-understand error message next to the email and username TextInputs.

Current approach (which doesn't seem to work)

I followed a guide in the Blazor documentation, which suggests using EditContext.OnFieldChanged. The function EditContext_OnFieldChanged is indeed invoked when I enter a value in the email field. I can then add manual validation messages to the messageStore; however, it doesn't trigger the form from not validating.

Question

It seems quite cumbersome for something that happens so often on public web sites. Do any of you know a better / correct way of doing this?

Current Blazor form code:

@page "/register"
@using [XXX].Data
@inject AuthenticationService websiteAuthenticator
@inject NavigationManager navManager;


<h3>Register new account</h3>

<AuthorizeView>
    <Authorized>
        <div Context="Rubbish"><h1>Have to redirect somehow...</h1></div>
    </Authorized>
    <NotAuthorized>
        <EditForm Context="Registration" EditContext="@EditContext" class="ui form" OnValidSubmit="@TryRegister">
            <h4 class="ui dividing header">New user account</h4>
            <DataAnnotationsValidator />
            <ValidationSummary />
            
            <div class="field">
                <label>About you</label>
                <small>Providing your name is optional. Your name or email will not be visible to other users.</small>
                <div class="two fields">
                    <div class="field">
                        <InputText type="text" @bind-Value="RegisterFormModel.FirstName" id="firstname" placeholder="First name"></InputText>
                    </div>
                    <div class="field">
                        <InputText type="text" @bind-Value="RegisterFormModel.LastName" id="lastname" placeholder="Last name"></InputText>
                    </div>
                </div>
            </div>
            <div class="field">
                <label>Username</label>                    
                <small>Choose your username wisely. The username will be visible to other users on the site.</small>
                <InputText type="text" @bind-Value="RegisterFormModel.Username" id="username"></InputText>
                <ValidationMessage For="() => RegisterFormModel.Username" />
            </div>
            <div class="field">
                <label>Email</label>                    
                <small>You must provide a working email address to register. Your email address will not be visible to other users. We will not share your email address with third parties.</small>
                <InputText type="email" @bind-Value="RegisterFormModel.Email" id="email"></InputText>
                <ValidationMessage For="() => RegisterFormModel.Email" />
            </div>
            <div class="field">
                <label>Password</label>
                <small>Passwords must be at least 8 characters long. Pick a secure one.</small>
                <InputText type="password" @bind-Value="RegisterFormModel.Password" id="password"></InputText>
                <ValidationMessage For="() => RegisterFormModel.Password" />
            </div>
            <div class="ui segment">
                <div class="field">
                    <div class="ui toggle checkbox">
                        <InputCheckbox id="rememberMe" @bind-Value="RegisterFormModel.Above18" />
                        <label>
                            I am aged 18 or above
                        </label>
                    </div>
                </div>
            </div>
            <button type="submit" class="ui button" disabled="@(!RegisterFormModel.Above18)">Register me</button>
        </EditForm>
        <XX.Shared.SemanticUI.Confirmation Show=@ShowConfirmation Header="Account created">
            Your account has been registered, but needs to be activated. Please check your inbox for an email with instructions on how to activate the account and log in.
        </XX.Shared.SemanticUI.Confirmation>
        <XX.Shared.SemanticUI.Warning Show=@ShowError Header="Registration error">
            An error occurred during registration of the account. Please contact us via <a href="/support">the support page.</a>
        </XX.Shared.SemanticUI.Warning>
                      
    </NotAuthorized>
</AuthorizeView>

@code {

    private EditContext? EditContext { get; set; }
    private ValidationMessageStore? messageStore;

    protected RegisterFormModel RegisterFormModel { get; set; } = new();

    protected bool ShowError { get; set; } = false;

    protected bool ShowConfirmation { get; set; } = false;

    protected override void OnInitialized()
    {
        EditContext = new EditContext(RegisterFormModel);
        EditContext.OnFieldChanged += EditContext_OnFieldChanged;
        base.OnInitialized();
    }

    private void EditContext_OnFieldChanged(object? sender, FieldChangedEventArgs e)
    {
        messageStore?.Clear();
        var fieldname = e.FieldIdentifier.FieldName;
        if (fieldname.ToLower().Equals("email"))
        {
            if (RegisterFormModel.Email.StartsWith("[TO BE REPLACED BY DATABASE LOOKUP LOGIC"))
            {
                messageStore?.Add(() => RegisterFormModel.Email, "Account already registered with email address"); 
            }
    }

    public void Dispose()
    {
        if (EditContext is not null)
        {
            EditContext.OnFieldChanged -= EditContext_OnFieldChanged;
        }

    }

    protected async void TryRegister()
    {
        var u = await websiteAuthenticator.CreateUserAsync(RegisterFormModel);
        if (u.Item2.Succeeded)
        {
            ShowConfirmation = u.Item2.Succeeded;
            StateHasChanged();
        }
        else
        {
            ShowError = true;
            StateHasChanged();

        }
    }
}

Code below for RegisterFormModel

    public class RegisterFormModel
    {
        [Required(ErrorMessage = "You must provide a user name")]
        [MinLength(3, ErrorMessage = "User name must have a minimum of 3 characters")]
        [RegularExpression(@"^\S*$", ErrorMessage = "No white space allowed in user name")]
        [Display(Name = "User name")]
        public string Username { get; set; }

        [Display(Name = "First name")]
        public string FirstName { get; set; } = string.Empty;
        
        [Display(Name = "Last name")]
        public string LastName { get; set; } = string.Empty; 

        [Required(ErrorMessage = "You must provide an email address to register.")]
        [MinLength(3)]
        [EmailAddress(ErrorMessage = "Invalid email address")]
        [Display(Name = "Email address")]
        public string Email { get; set; }

        [Required]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 8)]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }

        [Required]
        public bool Above18 { get; set; }

    }
1 Answers

You can create a custom validation component:

public class CustomValidation : ComponentBase
{
    private ValidationMessageStore? messageStore;

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

    protected override void OnInitialized()
    {
        if (CurrentEditContext is null)
        {
            throw new InvalidOperationException(
                $"{nameof(CustomValidation)} requires a cascading " +
                $"parameter of type {nameof(EditContext)}. " +
                $"For example, you can use {nameof(CustomValidation)} " +
                $"inside an {nameof(EditForm)}.");
        }

        messageStore = new(CurrentEditContext);

        CurrentEditContext.OnValidationRequested += (s, e) => 
            messageStore?.Clear();
        CurrentEditContext.OnFieldChanged += (s, e) => 
            messageStore?.Clear(e.FieldIdentifier);
    }

    public void DisplayErrors(Dictionary<string, List<string>> errors)
    {
        if (CurrentEditContext is not null)
        {
            foreach (var err in errors)
            {
                messageStore?.Add(CurrentEditContext.Field(err.Key), err.Value);
            }

            CurrentEditContext.NotifyValidationStateChanged();
        }
    }

    public void ClearErrors()
    {
        messageStore?.Clear();
        CurrentEditContext?.NotifyValidationStateChanged();
    }
}

Then use like this:

<EditForm Context="Registration" EditContext="@EditContext" class="ui form" OnValidSubmit="@TryRegister">
    <h4 class="ui dividing header">New user account</h4>
    <DataAnnotationsValidator />
    <ValidationSummary />
    
    @* Add CustomValidation component inside EditForm *@
    <CustomValidation @ref="customValidation" />
        
    ...
</EditForm>

@code {
    private CustomValidation customValidation;
    protected RegisterFormModel RegisterFormModel { get; set; } = new();

    private void HandleValidSubmit()
    {
        customValidation?.ClearErrors();

        var errors = new Dictionary<string, List<string>>();

        var emailExists = true; // make api call to check if email exists.
        if (emailExists)
        {
            errors.Add(nameof(RegisterFormModel.Email),
                new() { "Email already exists!" });
        }

        if (errors.Any())
        {
            customValidation?.DisplayErrors(errors);
        }
        else
        {
            Logger.LogInformation("HandleValidSubmit called: Processing the form");

            // Process the valid form
        }
    }
}

Code taken from microsoft documentation: https://docs.microsoft.com/en-us/aspnet/core/blazor/forms-validation?view=aspnetcore-6.0#validator-components

Related