Eric King
Posted on March 10, 2021
This is the fifth in a short series of blog posts where I will go beyond the introductory level and dig a bit deeper into using the Fluxor library in a Blazor Wasm project.
EditForm Databinding
So far we've spent a lot of time taking advantage of the unidirectional data flow inherent in the Flux pattern.
But one of the major benefits of using Blazor is being able to take advantage of your C# classes and .NET functionality in the browser as well as on the server.
A specific example is using an AspNetCore EditForm, binding it a Model class, and validating it automatically, both in the browser and on the server.
EditForm
, however, is based on two-way databinding to its Model
. Binding the form fields directly to a read-only State
object would not work.
To illustrate, let's create a UserFeedback feature in our application. Consider the following requirement:
A user can submit a feedback form, with three fields: Email Address, Rating (1 through 10), and a Comment. Upon successful submit, hide the form and display a success message.
It would be tempting to begin by creating a UserFeedbackState
record with those properties:
public record UserFeedbackState
{
public string EmailAddress { get; init; }
public int Rating { get; init; }
public string Comment { get; init; }
}
But it wouldn't take long to realize that the read-only nature of the record
with init
methods in place of set
makes two-way binding to the EditForm
impossible.
Instead, we need a traditional model class, where we can take advantage of DataAnnotations
. We'll create this model and place it in the Shared
project so that it can be referenced by the Blazor front-end and also by the server ApiController:
using System.ComponentModel.DataAnnotations;
namespace BlazorWithFluxor.Shared
{
public class UserFeedbackModel
{
[EmailAddress]
[Required]
[Display(Name = "Email Address")]
public string EmailAddress { get; set; }
[Required]
public int Rating { get; set; }
[MaxLength(100)]
public string Comment { get; set; }
public UserFeedbackModel()
{
EmailAddress = string.Empty;
Rating = 1;
Comment = string.Empty;
}
}
}
Back in the Client
project, let's put in place the folder structure \Features\UserFeedback
with subfolders for \Pages
and \Store
.
Create a UserFeedbackStore.cs
file in the \Store
folder, and begin with a UserFeedbackState and its Feature:
public record UserFeedbackState
{
public bool Submitting { get; init; }
public bool Submitted { get; init; }
public string ErrorMessage { get; init; }
public UserFeedbackModel Model { get; init; }
}
public class UserFeedbackFeature : Feature<UserFeedbackState>
{
public override string GetName() => "UserFeedback";
protected override UserFeedbackState GetInitialState()
{
return new UserFeedbackState
{
Submitting = false,
Submitted = false,
ErrorMessage = string.Empty,
Model = new UserFeedbackModel()
};
}
}
I've added a few properties to the UserFeedbackState to represent the state of the component: Submitting, Submitted, ErrorMessage. These represent the state of the form, but not the values in the form.
The values in the form will be databound to the Model
property, where I'm cheating compromising a bit by using an init-only object but with read/write properties.
Note: I'm certain that this technique isn't strictly adherent to the "immutable state" approach of Flux, since technically some state is being mutated without going through a reducer. But I think this specific and limited situation is an acceptable exception to the rule, given the benefits.
For Actions, we only need a couple:
public class UserFeedbackSubmitSuccessAction { }
public class UserFeedbackSubmitFailureAction
{
public string ErrorMessage { get; }
public UserFeedbackSubmitFailureAction(string errorMessage)
{
ErrorMessage = errorMessage;
}
}
public class UserFeedbackSubmitAction
{
public UserFeedbackModel UserFeedbackModel { get; }
public UserFeedbackSubmitAction(UserFeedbackModel userFeedbackModel)
{
UserFeedbackModel = userFeedbackModel;
}
}
We'll need one Effect for the form submit:
public class UserFeedbackEffects
{
private readonly HttpClient _httpClient;
public UserFeedbackEffects(HttpClient httpClient)
{
_httpClient = httpClient;
}
[EffectMethod]
public async Task SubmitUserFeedback(UserFeedbackSubmitAction action, IDispatcher dispatcher)
{
var response = await _httpClient.PostAsJsonAsync("Feedback", action.UserFeedbackModel);
if (response.IsSuccessStatusCode)
{
dispatcher.Dispatch(new UserFeedbackSubmitSuccessAction());
}
else
{
dispatcher.Dispatch(new UserFeedbackSubmitFailureAction(response.ReasonPhrase));
}
}
}
The EffectMethod will accept the Model
as part of the action
, and use the injected HttpClient
to post it to the ApiController we'll soon create. It will dispatch either a Success or Failure action when it's done.
And finally for the store, the ReducerMethods:
public static class UserFeedbackReducers
{
[ReducerMethod(typeof(UserFeedbackSubmitAction))]
public static UserFeedbackState OnSubmit(UserFeedbackState state)
{
return state with
{
Submitting = true
};
}
[ReducerMethod(typeof(UserFeedbackSubmitSuccessAction))]
public static UserFeedbackState OnSubmitSuccess(UserFeedbackState state)
{
return state with
{
Submitting = false,
Submitted = true
};
}
[ReducerMethod]
public static UserFeedbackState OnSubmitFailure(UserFeedbackState state, UserFeedbackSubmitFailureAction action)
{
return state with
{
Submitting = false,
ErrorMessage = action.ErrorMessage
};
}
}
The ReducerMethods are straight-forward, just keeping track of the few properties that we'll use to decide what to display on the screen.
In the UserFeedback\Pages
folder we'll add a Feedback.razor file as the page to hold the form. The EditForm will look like:
<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<div class="form-group">
<label for="emailAddress">Email Address</label>
<InputText class="form-control" id="emailAddress" @bind-Value="model.EmailAddress" />
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<InputSelect class="form-control" id="rating" @bind-Value="model.Rating">
<option>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
<option>5</option>
<option>6</option>
<option>7</option>
<option>8</option>
<option>9</option>
<option>10</option>
</InputSelect>
</div>
<div class="form-group">
<label for="comment">Comment</label>
<InputTextArea class="form-control" id="comment" @bind-Value="model.Comment" rows="3"></InputTextArea>
</div>
<button class="btn btn-primary" type="submit">Submit</button>
<ValidationSummary />
</EditForm>
The form has all the AspNetCore goodness: two-way databinding, DataAnnotationsValidator, ValidationSummary, etc. The HandleValidSubmit
method will only be invoked once all of the form fields pass all validation.
The entire razor page, including the @code
block and all of the code deciding which portions of the screen to display is below:
@page "/feedback"
@inherits FluxorComponent
@using BlazorWithFluxor.Client.Features.UserFeedback.Store
@inject IState<UserFeedbackState> UserFeedbackState
@inject IDispatcher Dispatcher
<h3>User Feedback</h3>
@if (UserFeedbackState.Value.Submitting)
{
<div>
Submitting... Please wait.
</div>
}
else if (UserFeedbackState.Value.Submitted && string.IsNullOrWhiteSpace(UserFeedbackState.Value.ErrorMessage))
{
<div class="alert alert-success">
Thank you for sharing!
</div>
}
else
{
<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<div class="form-group">
<label for="emailAddress">Email Address</label>
<InputText class="form-control" id="emailAddress" @bind-Value="model.EmailAddress" type="email" />
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<InputSelect class="form-control" id="rating" @bind-Value="model.Rating">
<option>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
<option>5</option>
<option>6</option>
<option>7</option>
<option>8</option>
<option>9</option>
<option>10</option>
</InputSelect>
</div>
<div class="form-group">
<label for="comment">Comment</label>
<InputTextArea class="form-control" id="comment" @bind-Value="model.Comment" rows="3"></InputTextArea>
</div>
<button class="btn btn-primary" type="submit">Submit</button>
<ValidationSummary />
</EditForm>
}
@if (!string.IsNullOrWhiteSpace(UserFeedbackState.Value.ErrorMessage))
{
<div class="alert alert-danger">
Error: @UserFeedbackState.Value.ErrorMessage
</div>
}
@code {
private UserFeedbackModel model => UserFeedbackState.Value.Model;
private void HandleValidSubmit()
{
Dispatcher.Dispatch(new UserFeedbackSubmitAction(UserFeedbackState.Value.Model));
}
}
To get to the page we add an item to the NavMenu
:
<li class="nav-item px-3">
<NavLink class="nav-link" href="feedback">
<span class="oi oi-list-rich" aria-hidden="true"></span> Feedback
</NavLink>
</li>
And finally, we need a place to POST the form to. Let's create a FeedbackController in the Server project:
using BlazorWithFluxor.Shared;
using Microsoft.AspNetCore.Mvc;
using System;
namespace BlazorWithFluxor.Server.Controllers
{
[ApiController]
[Route("[controller]")]
public class FeedbackController : ControllerBase
{
[HttpPost]
public void Post(UserFeedbackModel model)
{
var email = model.EmailAddress;
var rating = model.Rating;
var comment = model.Comment;
Console.WriteLine($"Received rating {rating} from {email} with comment '{comment}'");
}
}
}
The Post
action receives the same UserFeedbackModel
class that the EditForm
was bound to. We can (and should) re-validate the model here before further processing, but for this example I'm just going to log the contents of the model to the console.
As you can see, all of the built-in features of the EditForm and DataAnnotationsValidator are available, plus the state of the form was maintained by Fluxor when I navigated away from and then back to the form. The best of both worlds.
Happy Coding!
Posted on March 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
June 27, 2024