2024-01-30
EngineeringCascading Dropdowns With Blazor SSR
When .NET 8 was released, it brought with it a brand new capability: Server Side Rendering (SSR) for Blazor. Blazor devs have historically had a choice between Server Interactivity (powered by a constant SignalR connection) and Client Side Interactivity (powered by WebAssembly). Aggregations.io was written first as a Server Interactive app but swapped over to fully WASM. With the release of .NET 8, some components have been migrated to SSR. This post is our first in an attempt to give back to the Blazor community, sharing our learnings and hopefully helping to push the Blazor community forward.
You might ask yourself, “SSR, didn’t we have that with Razor Pages?” and you’d be mostly right. Except Blazor has added some enhancements that can enable .NET developers to have the benefits (performance, security, simplicity) of SSR, along with elements of interactivity, with no barely any Javascript required. Additionally, you can mix-and-match render modes to integrate WASM or Server Interactive components alongside your SSR content.
We’re going to focus on just SSR today. You can find all the code for this post in our new Blazor Demos GitHub repo.
Our Goal
We want a form with dependent dropdowns, with as little JS as possible using Blazor/C#. This is the Aggregations.io blog, so we’re going to look at building up a very basic Aggregations.io query tool, but the same mechanisms can be applied anywhere.
Here’s a sneak peek of the result:
Contents
- Project Setup We will walk through the
Program.cs
, configure a service to call the API and set up the necessary models. - UX All the razor goodness. We’ll dive into all the necessary components, binding them to an
EditForm
and ensuring we get the best user experience possible. - Logic/CodeLooking at the code that enables interactivity when our form is submitted and some lightweight validation.
- JavaScriptWe need a tiny amount of JavaScript in order to facilitate the feeling of interactivity and to submit our form. Also, why?
- ConclusionTake a look at the final product and see what is coming up in the next iteration of this project.
Project Setup:
We set up our Program.cs
like so:
using Cascading_Dropdowns.Services;
using Cascading_Dropdowns.Components;
namespace Cascading_Dropdowns;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IAggregationsService, AggregationsService>(client =>
{
client.BaseAddress = new Uri("https://app.aggregations.io/api/v1/");
client.DefaultRequestHeaders.Add("x-api-token",
builder.Configuration["Aggregations:ApiToken"]);
});
builder.Services.AddRazorComponents();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>();
app.Run();
}
}
We’re setting up a pretty typical Blazor app, with a few modifications from the the basic Web App template.
We have AddRazorComponents()
and MapRazorComponents<App>()
- but no .AddInteractiveServerRenderMode()
or .AddInteractiveWebAssemblyRenderMode()
.
We’re also adding a typed HttpClient for Aggregations.io in the form of our AggregationsService
which will handle the interactions with the API. You can see the code for that below.
public interface IAggregationsService
{
public IAsyncEnumerable<AggregationsResult> GetResults(AggregationsRequest request);
public IAsyncEnumerable<FilterDefinition> GetFilters();
}
public class AggregationsService(HttpClient client) : IAggregationsService
{
public async IAsyncEnumerable<AggregationsResult> GetResults(AggregationsRequest request)
{
var resp = await client
.PostAsJsonAsync("metrics/results", request);
resp.EnsureSuccessStatusCode();
await foreach (var r in resp.Content.ReadFromJsonAsAsyncEnumerable<AggregationsResult>())
{
yield return r;
}
}
public IAsyncEnumerable<FilterDefinition?> GetFilters() =>
client
.GetFromJsonAsAsyncEnumerable<FilterDefinition>("filter-definitions");
}
The service allows us to GET Filter definitions and POST for results. We’re mapping our requests and responses to C# entities for ease of development - another benefit of Blazor; no need to deal with arbitrary payload serialization or type definitions in multiple languages.
We’re going to need a few models:
The FilterDefinition
holds the baseline info about our filters, and within each filter is a list of Aggregation
- which then cascade to our CalculationType
options.
We retrieve these with a simple GET request to the Aggregations.io API.
public record FilterDefinition ( string id, string name, Aggregation[] aggregations );
public record Aggregation ( int id, string name, [property:JsonConverter(typeof(CalculationTypeListConverter))] List<CalculationType> calculations );
public enum CalculationType { COUNT, SUM, MAX, MIN, AVG, APPROX_COUNT_DISTINCT, PERCENTILES }
JsonConverter
there, that’s because the standard JsonStringEnumConverter
doesn’t handle collections, you can see the full code in the linked source code. The following model wraps necessary parameters for the Metrics API.
public class AggregationsRequest
{
public required string filterId { get; set; }
public int aggregationId { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public CalculationType calculation { get; set; }
public DateTime startTime { get; set; }
public DateTime endTime { get; set; }
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public double? percentile { get; set; }
public bool excludeEmptyGroupings => true;
}
We also need a model to represent the results from the API, which we will use in the next post on building interactive visualizations with Blazor SSR.
public readonly record struct AggregationsResult(DateTime dt, Dictionary<string,string>? groupings, double value);
The UX:
We are designing a very basic form here. We’ve got our “query builder” on the left and eventually, our chart on the right.
To accomplish our goal, we need to keep track of our form state and use that when submitting our form to the backend. The original implementation used multiple nested forms, but after a lot of trial & error, a single EditForm
with a consistently tracked Context
object yielded the best results both in terms of functionality and in code simplicity.
For this, we need one more model. This is our FilterSetupContext
, which holds the necessary state and keeps track of which control triggered the form submission. It also has a very basic form of validation, to define whether or not the button should be disabled.
The other benefit here comes from wrapping our AggregationsRequest
in this context, so we can access our selected filter & aggregations.
public class FilterSetupContext
{
public enum SubmitType { None, GoBtn, Filter, Aggregation, Calculation, Dates }
[JsonIgnore]
public List<FilterDefinition>? Filters { get; set; }
public FilterDefinition? SelectedFilter => Filters?.FirstOrDefault(x => x.id == CurrentRequest.filterId);
public Aggregation? SelectedAggregation =>
SelectedFilter?.aggregations.FirstOrDefault(x => x.id == CurrentRequest.aggregationId);
public required AggregationsRequest CurrentRequest { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public SubmitType Type { get; set; }
public string? PercentileStr { get; set; }
public bool Submittable => SelectedFilter != null && SelectedAggregation != null &&
SelectedAggregation.calculations.Contains(CurrentRequest.calculation) &&
CurrentRequest.startTime < CurrentRequest.endTime;
}
You can see the full razor code
We’re going to walk through some important aspects of our frontend code:
Stream Rendering
@attribute [StreamRendering]
Setting this attribute is crucial, it enables us to make the page feel interactive, even though it’s not. When we are fetching our list of filters or making logic decisions based on the form submits - the server begins to return the page almost instantly, but then, depending on those results, our variables like results_loading
change, and the HTML components change too.
Edit Form
<EditForm class="row g-3" Enhance="true" FormName="charts" Model="ctx" OnSubmit="@FormSubmitted" id="chartsForm">
<input type="hidden" id="submitType" @bind-value="ctx.Type" name="@($"{nameof(ctx)}.{nameof(ctx.Type)}")"/>
@if (ctx.Filters != null)
{ ...
We set up our EditForm
with Enhance
set to true, giving it our Model object, a method to perform when submitted and both a name & ID. The name is important for binding to our parameter in the code, the ID is useful for our little Javascript we need to write.
We also create our hidden input to hold our submitType
We bind its value to the Type
field on our context, but in order for the form binding to work correctly, its name must match the pattern the SSR code expects. {variable}.{field}
.
You can also begin to see where the stream rendering comes into play - because we won’t even render the inputs unless we know we’ve gotten our Filters set.
Inputs
We’ve got our first InputSelect
bound to the filterId
(line 5) and we set it to indicate a Filter
changed (line 7).
<InputSelect
disabled="@results_loading"
class="form-control form-select"
TValue="string"
@bind-Value="ctx.CurrentRequest.filterId"
Id="filterSelector"
data-change_type="@FilterSetupContext.SubmitType.Filter">
@if (ctx.SelectedFilter == null)
{
<option value="@string.Empty">Select a Filter</option>
}
@foreach (var f in ctx.Filters)
{
<option value="@f.id" selected="@(ctx.CurrentRequest.filterId == f.id)">@f.name</option>
}
</InputSelect>
The next one up is our Aggregation
selector, bound to the CurrentRequest.aggregationId
(line 6).
As a reminder, we can’t choose our aggregation unless our filter is already selected. We ensure this in 2 ways:
- We disable the selector if the
SelectedFilter
is null (line 4) - We don’t add any options if the
SelectedFilter
is null (line 10)
<div class="col-xl-6">
<label class="form-label" for="aggregationSelector">Aggregation</label>
<InputSelect
disabled="@(results_loading || ctx.SelectedFilter==null)"
class="form-control form-select"
@bind-Value="@ctx.CurrentRequest.aggregationId"
TValue="int"
Id="aggregationSelector"
data-change_type="@FilterSetupContext.SubmitType.Aggregation">
@if (ctx.SelectedFilter != null)
{
@foreach (var a in ctx.SelectedFilter.aggregations)
{
<option value="@a.id">@a.name</option>
}
}
</InputSelect>
</div>
We spice it up a bit, using a radio group for the calculation options:
<div class="col-12">
@if (ctx.SelectedAggregation?.calculations != null)
{
<label class="form-label">Calculation</label>
<InputRadioGroup
class="form-check"
TValue="CalculationType"
@bind-Value="ctx.CurrentRequest.calculation">
@foreach (var o in ctx.SelectedAggregation.calculations)
{
<div class="col-12">
<InputRadio
disabled="@results_loading"
TValue="CalculationType"
Value="@o"
data-change_type="@FilterSetupContext.SubmitType.Calculation"
class="form-check-input calc-radio"
id="@($"radio{o}")"/>
<label for="@($"radio{o}")" class="form-check-label">
<code class="fw-bold">@o</code>
</label>
</div>
}
</InputRadioGroup>
}
</div>
Next up, we have our Percentile input, which is bound as a string. It seems that binding to a Double?
caused some issues for the InputNumber
component. We can work around that.
Similar to our hidden
input above, we need to ensure we properly name the input, so the parameters will match correctly (line 11).
We also apply the handy d-none
class if the selected calculation is not PERCENTILES
to hide the input unless necessary.
<div class="col-12 @(ctx.CurrentRequest.calculation == CalculationType.PERCENTILES ? "" : "d-none")">
<label class="form-label">Percentile</label>
<input
disabled="@results_loading"
type="number"
min="0"
max="1"
step="0.05"
@bind-value="ctx.PercentileStr"
class="form-control"
name="@($"{nameof(ctx)}.{nameof(ctx.PercentileStr)}")"/>
</div>
Finally, we have a couple simple inputs for our timeframe. Originally this used the built-in InputDate
component, however, after realizing an issue with validation on Safari, a quick swap to plain inputs fixed it up.
The issue is that the standard Blazor InputDate formats the value as a full timestamp like 2024-01-10T14:48:11
, however, Safari seems to dislike the seconds value there. No issue though, we just need to ensure our name is correct and we format the values as yyyy-MM-ddTHH:mm
.
<div class="col-xl-6">
<label class="form-label" for="StartTimeDt">Start Time</label>
<input type="datetime-local"
value="@(ctx.CurrentRequest.startTime.ToString("yyyy-MM-ddTHH:mm"))"
id="StartTimeDt"
disabled="@results_loading"
class="form-control"
data-change_type="@FilterSetupContext.SubmitType.Dates"
name="@($"{nameof(ctx)}.{nameof(ctx.CurrentRequest)}.{nameof(ctx.CurrentRequest.startTime)}")"/>
</div>
<div class="col-xl-6">
<label class="form-label" for="EndTimeDt">End Time</label>
<input type="datetime-local"
value="@(ctx.CurrentRequest.endTime.ToString("yyyy-MM-ddTHH:mm"))"
id="EndTimeDt"
disabled="@results_loading"
class="form-control"
data-change_type="@FilterSetupContext.SubmitType.Dates"
name="@($"{nameof(ctx)}.{nameof(ctx.CurrentRequest)}.{nameof(ctx.CurrentRequest.endTime)}")"/>
</div>
Button(s)
In order to submit the form, we need a button. However, we don’t want users to actually click that button; so we add 2 buttons. One for clicking, which has a type = "button"
and then the one we will programmatically click with type = "submit"
. We need to do this to give our javascript snippet to set the hidden
values correctly, and in order to trigger submits when dropdowns or radio selections change.
<div class="col-12">
@* Button for clicking *@
<button
disabled="@(results_loading || !ctx.Submittable)"
class="btn btn-success"
id="goBtn"
type="button"
data-change_type="@FilterSetupContext.SubmitType.GoBtn"
>Go</button>
@* Button for submitting *@
<button class="btn btn-success d-none" id="submitBtn" type="submit"></button>
</div>
Logic/Code:
The nice thing about this approach is that we’re still not dealing with Javascript.
Let’s take a look at our @code{}
(full code here).
We start with just 3 key variables:
[Parameter, SupplyParameterFromForm(FormName = "charts")]
public FilterSetupContext? ctx { get; set; }
private bool results_loading { get; set; }
private List<AggregationsResult>? results { get; set; }
We wrap most of the validation and logic up into a simple method, because we need to call this both when the page initializes and when the form is submitted. In a more realistic scenario, you’d do things like rigorous validation as well.
[MemberNotNull(nameof(ctx))]
private async Task CleanupCtx()
{
if (ctx == null)
{
ctx = new()
{
CurrentRequest = new()
{
filterId = string.Empty,
startTime = DateTime.UtcNow.AddDays(-14),
endTime = DateTime.UtcNow
}
};
}
if (ctx.Filters == null)
{
ctx.Filters = new List<FilterDefinition>();
await foreach (var f in _svc.GetFilters())
{
if (f != null)
{
ctx.Filters.Add(f);
}
}
}
if (ctx.SelectedFilter != null && ctx.SelectedAggregation == null)
{
ctx.CurrentRequest.aggregationId = ctx.SelectedFilter.aggregations.First().id;
}
if (ctx.SelectedAggregation != null)
{
if (!ctx.SelectedAggregation.calculations.Contains(ctx.CurrentRequest.calculation))
{
ctx.CurrentRequest.calculation = ctx.SelectedAggregation.calculations.First();
}
if (ctx.CurrentRequest.calculation == CalculationType.PERCENTILES)
{
if (double.TryParse(ctx.PercentileStr ?? String.Empty, out double _d))
{
ctx.CurrentRequest.percentile = _d;
}
}
}
}
Let’s walk through some key lines:
- 1: This is a handy annotation that says “Hey compiler, after this method runs, the
ctx
variable won’t be null, guaranteed!” Just removes some of those pesky warnings for nullable types.-
[MemberNotNull(nameof(ctx))]
-
- 19:
Filters
will always be null, and in a more robust scenario - you’d want to have some short local cache for this. Or in a different cascading use-case, it might not matter if the possible options are constant. But we fetch the list of filters from the service and add them to the options.-
if (ctx.Filters == null) { ctx.Filters = new List
(); await foreach (var f in _svc.GetFilters()) { if (f != null) { ctx.Filters.Add(f); } } }
-
- 29: If our
SelectedFilter
is not null and ourSelectedAggregation
is null, that means either (a) the user hasn’t selected an aggregation yet at all or (b) they switched from one filter to a different one, which no longer has an aggregation matching that ID. In this case, we select the first Aggregation.-
if (ctx.SelectedFilter != null && ctx.SelectedAggregation == null) { ctx.CurrentRequest.aggregationId = ctx.SelectedFilter.aggregations.First().id; }
-
- 36: If our
SelectedAggregation
is not null (aka all normal circumstances), we try and maintain the previously selected Calculation. If the newly selected Aggregation doesn’t contain it as an option, we set the selected Calculation to the first option.-
if (!ctx.SelectedAggregation.calculations.Contains(ctx.CurrentRequest.calculation)) { ctx.CurrentRequest.calculation = ctx.SelectedAggregation.calculations.First(); }
-
- 41/43: We do our percentile parsing here, to ensure it’s a proper double and the selected calculation is actually
PERCENTILES
.-
if (ctx.CurrentRequest.calculation == CalculationType.PERCENTILES) { if (double.TryParse(ctx.PercentileStr ?? String.Empty, out double _d)) { ctx.CurrentRequest.percentile = _d; } }
-
The CleanupCtx
method is called when initializing the page, ensuring the context is always up to date.
protected override async Task OnInitializedAsync()
{
await CleanupCtx();
}
Finally, we have our method that runs when the form is submitted. Because the component is initialized, the ctx
should not be null (and we ensure that with an exception).
If the submit was triggered by the GoBtn
and it is submittable - we go ahead and fetch the results.
private async Task FormSubmitted(EditContext obj)
{
ArgumentNullException.ThrowIfNull(ctx);
if (ctx.Type == FilterSetupContext.SubmitType.GoBtn && ctx.Submittable)
{
results_loading = true;
results = new List<AggregationsResult>();
var res = _svc.GetResults(ctx.CurrentRequest);
await foreach (var r in res)
{
results.Add(r);
}
results_loading = false;
}
}
Javascript
We need just a tiny bit of JavaScript to really make the page feel interactive.
Why not just go full interactive then?
We could, but so much is accomplishable with forms & enhanced navigation in Blazor SSR, that adding in all the excess complexity is not always necessary.
- Server Interactivity means the client will always have a WebSocket/SignalR connection open. This is potentially fragile and can give a subpar experience, especially if you have frequent deploys of your application. The reconnection behavior was the main driver for moving Aggregations.io from Server Interactivity to WASM.
- WebAssembly brings its own tradeoffs. Firstly, you have to contend with loading states and WASM downloads, which for a relatively simple use-case like this - aren’t warranted. Further, you’ll need to implement an actual API layer to handle form submissions, vs the auto-magical enhanced form handling SSR offers.
We use JavaScript to listen to actions (dropdown selections, date changes, button clicks) and programmatically click our hidden submit button. No heavy logic or data handling is happening.
We start with a couple top line variables.
- We define a
loaded
variable because we’re going to add our listeners potentially twice, depending on when the script gets called. More on this below - We have a
PrevCalculationRadio
to keep track of the previous Calculation value when a radio selection changed. We do this so we don’t submit the form unless the new value isPERCENTILES
or the old value wasPERCENTILES
. - We have our constant so we don’t need to type
PERCENTILES
everywhere.
let loaded = false;
let PrevCalculationRadio = '';
const PercentilesVal = 'PERCENTILES';
Next up, we have the code that actually attaches listeners to the document, so we can react when inputs are changed.
function AddListeners() {
const go_btn = document.getElementById('goBtn');
if (!loaded && go_btn !== undefined) {
loaded = true;
go_btn.addEventListener('click', function (t) {
SetHiddenAndSubmit(t);
});
document.addEventListener('change', function (e) {
if (e.target.classList.contains('calc-radio')) {
var currVal = e.target.value;
if (
(currVal === PercentilesVal && PrevCalculationRadio !== PercentilesVal)
||
(currVal !== PercentilesVal && PrevCalculationRadio === PercentilesVal)
) {
SetHiddenAndSubmit(e);
}
PrevCalculationRadio = currVal;
} else {
if (e.target.dataset['change_type'] !== undefined) {
SetHiddenAndSubmit(e);
}
}
});
}
}
Let’s walk through some key lines:
- 3: Only run this code once, ensuring the
goBtn
exists andloaded
hasn’t been set yet.-
if (!loaded && go_btn !== undefined) {
-
- 5: Adding a listener for
click
on thegoBtn
, we pass the params down to ourSetHiddenAndSubmit
logic.-
go_btn.addEventListener('click', function (t) { SetHiddenAndSubmit(t); });
-
- 9: Here we’re adding a listener to any change event in the document. This isn’t necessarily the best way to accomplish this, but since Blazor will be replacing/dynamically creating elements - listening for all changes makes this far simpler.
-
document.addEventListener('change', function (e) {
-
- 10: If the target of the change includes the
calc-radio
class, we want to employ our decision whether to submit or not.-
if (e.target.classList.contains('calc-radio'))
-
- 13-15 / 19: We compare the
currVal
to the percentiles constant and the previous known value. If either condition matches, we’ll callSetHiddenAndSubmit
. Whether it matches or not, we set ourPrevCalculationRadio
to the current value so that we can make an accurate comparison next time the radio changes.-
if ( (currVal === PercentilesVal && PrevCalculationRadio !== PercentilesVal) || (currVal !== PercentilesVal && PrevCalculationRadio === PercentilesVal) ) { SetHiddenAndSubmit(e); } PrevCalculationRadio = currVal;
-
- 21: If the target contains the
change_type
data entry, we know it’s a change we care about. This means if we want to expand the selectors in the future, we don’t have to alter the javascript.-
if (e.target.dataset['change_type'] !== undefined) { SetHiddenAndSubmit(e); }
-
Our SetHiddenAndSubmit method pulls the change_type
out of the passed-in target and sets the value of our hidden input submitType
to that value. Then it clicks the actual submitBtn
.🎉
function SetHiddenAndSubmit(t) {
var type = t.target.dataset['change_type'];
document.getElementById('submitType').value = type;
const submitBtn = document.getElementById('submitBtn');
submitBtn.click();
}
Finally, we have one more bit of code:
AddListeners();
Blazor.addEventListener('enhancedload', function () {
AddListeners();
var radioChecked = document.querySelector('input[type="radio"][checked]');
if (radioChecked !== null) {
radioChecked.checked = true;
}
});
This does 2 things:
- Calls
AddListeners
when the script is first executed. - Adds an event listener for Blazor’s enhanced load. When enhanced load is called, we again make sure the listeners are wired up (presumably now the full page should be streamed/downloaded).
checked
(they’ll have the html property of checked
but not the attribute) which is crucial. The quick workaround here was to ensure that a checked radio is indeed marked as checked
when an ehnahcned load completes. Conclusion
We now have an interactive feeling page, and as you can see in the video below - changing dropdowns & selections triggers an HTTP fetch
, and Blazor magic takes care of the rest.
Be sure to check back for the follow-up, where we click the button and generate an interactive chart, still with minimal javascript. You can also ⭐️ our new Blazor Demos repo to get notified as we add more Blazor content.