Blog / Engineering

Cascading Dropdowns With Blazor SSR

2024-01-30

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:

Blazor Cascading Example

Contents

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 }
You may notice the use of a custom 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:

Example Verifications

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:

  1. We disable the selector if the SelectedFilter is null (line 4)
  2. 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:

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;
    }
}
Again, this is not super rigorous, you probably want to include better validation, error states & exception handling.

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.

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.

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:

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:

  1. Calls AddListeners when the script is first executed.
  2. 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).
There also appears to be some odd behavior with radio button state after an enhanced load – such that radio options not previously available, won’t be marked as 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.

Get started with Aggregations.io today. Sign up for a free trial (no credit card required), and discover how much power comes from real-time metrics based on your analytics data.