As some of my colleagues and friends may already know, I’m a live concert enthusiast. I think I’ve been to hundreds of concerts since the age of 14. But as I get older, it becomes more complicated to remember them all. That’s the idea behind this sample application. It might be a bit over-engineered, but it also serves as a demonstration project for using Blazor with ASP.NET Core and the Fluent Design Language.

This project will demonstrate the following Blazor topics:

  • Navigation
  • URLs for pages
  • Displaying data
  • Editing data
  • Dialogs

App Structure

When you start from scratch, as I described in this post, you’ll have a quite simple project structure with a few sample pages.

What I really like about Blazor is that you can structure your folders as you like, without affecting the final URL of the pages. This can be controlled completely independently.

For example, the artists list of my application is in the folder /Components/Pages/Artists/Index.razor.

In the code of this file, the @page attribute defines the route of this page.

Some examples in the Razor file can look like this:

@page "/artists"
@page "/artist/{ItemID:guid}"
@page "/customer/{customerId}/buildinggroup/{buildingGroupId}/calculation/{calculationId}"

This leads to quite simple URLs for my page about Artists, such as https://www.myawesomeconcertdatabase.com/artists or https://www.myawesomeconcertdatabase.com/artist/123456.

The following image describes the structure of the websites I build in this project. I also created some additional folders and files that contain more of the business logic, which we will discuss later.

Project Structure

For navigation, it is quite common to use the hamburger menu with flyouts. The template uses this, and so do I.

Project Structure

The navigation menu on the left side of the app can be configured via NavMenu.razor:

@rendermode InteractiveServer

<div class="navmenu">
    <input type="checkbox" title="Menu expand/collapse toggle" id="navmenu-toggle" class="navmenu-icon" />
    <label for="navmenu-toggle" class="navmenu-icon"><FluentIcon Value="@(new Icons.Regular.Size20.Navigation())" Color="Color.Fill" /></label>
    <nav class="sitenav" aria-labelledby="main-menu" onclick="document.getElementById('navmenu-toggle').click();">
        <FluentNavMenu Id="main-menu" Collapsible="true" Width="250" Title="Navigation menu" @bind-Expanded="expanded">
            <FluentNavLink Href="/" Match="NavLinkMatch.All" Icon="@(new Icons.Regular.Size20.Home())" IconColor="Color.Accent">Home</FluentNavLink>
            <FluentNavLink Href="artists" Icon="@(new Icons.Regular.Size20.BuildingLighthouse())" IconColor="Color.Accent">Artists</FluentNavLink>
            <FluentNavLink Href="concerts" Icon="@(new Icons.Regular.Size20.People())" IconColor="Color.Accent">Concerts</FluentNavLink>
        </FluentNavMenu>
    </nav>
</div>

@code {
    private bool expanded = true;
}

The component <FluentNavLink Href="artists" ...>Artists</FluentNavLink> will generate an <a href> to our artist page, which contains the path defined by @page "/artists".

NavMenu is just a part of another file called MainLayout.razor. This demonstrates quite well the way of building components in Blazor. The file NavMenu.razor is a component that is used in MainLayout.razor as the HTML tag <NavMenu/>, which I personally really like. MainLayout.razor:

@inherits LayoutComponentBase

<FluentLayout>
    <FluentHeader>
        Olivers Concert Database
    </FluentHeader>
    <FluentStack Class="main" Orientation="Orientation.Horizontal" Width="100%">
        <NavMenu />
        <FluentBodyContent Class="body-content">
            <div class="content">
                @Body
                <FluentDialogProvider @rendermode="RenderMode.InteractiveServer" />
            </div>
        </FluentBodyContent>
    </FluentStack>
    <FluentFooter>
       <a style="vertical-align:middle" href="https://www.medialesson.de" target="_blank">
            Made with
            <FluentIcon Value="@(new Icons.Regular.Size12.Heart())" Color="@Color.Warning" />
            by Medialesson
        </a>
    </FluentFooter>
</FluentLayout>

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

Display Data aka The Artists

Please assume that we are using Entity Framework in combination with the repository pattern here. You can see the details of the implementation in the source code that I will reference at the end of this post.

Components/Pages/Artists/Index.razor:

@page "/artists"
@using ConcertDatabase.Components.Pages.Artists.Panels
@using ConcertDatabase.Entities
@using ConcertDatabase.Repositories
@inject IDialogService dialogService
@inject ArtistRepository repository
@inject NavigationManager navigationManager

@rendermode InteractiveServer

<h3>Artist List</h3>

<FluentButton IconStart="@(new Icons.Regular.Size16.Add())" OnClick="@(() => AddInDialog())">Add</FluentButton>

@if (artists != null)
{
    <FluentDataGrid Items="@artists" TGridItem="Artist" Pagination="@pagination">
        <PropertyColumn Property="@(c => c.Name)" Sortable="true" />
        <PropertyColumn Property="@(c => c.Description)" Sortable="true" />
        <TemplateColumn Title="Actions">
            <FluentButton IconStart="@(new Icons.Regular.Size16.Edit())" OnClick="@(() => EditInDialog(context))" />
            <FluentButton IconStart="@(new Icons.Regular.Size16.DesktopEdit())" OnClick="@(() => EditInPanel(context))" />
            <FluentButton IconStart="@(new Icons.Regular.Size16.Delete())" OnClick="@(() => DeleteItem(context))" />
            <FluentButton IconStart="@(new Icons.Regular.Size16.Glasses())" OnClick="@(() => ShowItem(context))" />
        </TemplateColumn>
    </FluentDataGrid>

    <FluentPaginator State="@pagination" />
}
else
{
    <p><em>Loading...</em></p>
}

@code {
    IQueryable<Artist>? artists;
    PaginationState pagination = new PaginationState { ItemsPerPage = 15 };

    protected override void OnInitialized()
    {
        LoadData();
    }

    private void LoadData()
    {
        artists = repository.Entities.ToList().AsQueryable();
    }
    ... more code  ...
}

Some explanations here:

  1. The code at the top defines the route with @page, imports some namespaces with @using, and injects some dependency services with @inject.
  2. It also defines the render mode. You can have different render modes in Blazor. @rendermode InteractiveServer enables interaction with server code.
  3. The <FluentDataGrid> is the table definition of what we want to render. It contains the data in the Items property and enables pagination.
  4. Several actions are defined to demonstrate some interesting features. These features are triggered through the OnClick event, which calls methods like EditInDialog with the current row’s data.
  5. The @code area is essentially the code-behind. You can create a separate code-behind file if you prefer.
  6. In the @code section, I define the variable artists and fill it in the LoadData method with data from a database.

You can see the result of this little code snippet, which looks almost like pure HTML.

Artists

Delete Existing Data

I understand that not everyone is a fan of my music. I can tolerate that, most of the time. :-)

In case you want to delete an entry, you can click the delete symbol in the data grid. The code behind this method is in the @code section of the same file as the “HTML.” You remember the OnClick event in the code above? This event calls the following C# function.

private async Task DeleteItem(Artist item)
{
    // Check if the item is null
    if (item is null)
    {
        return;
    }

    // Create and show a dialog to confirm the delete
    IDialogReference dialog = await dialogService.ShowConfirmationAsync(
        $"Are you sure you want to delete the artist '{item.Name}'?",
        "Yes", 
        "No", 
        "Delete Artist?");
    DialogResult result = await dialog.Result;

    // If cancelled, return
    if (result.Cancelled)
    {
        return;
    }

    // Delete the item
    try
    {
        repository.Delete(item);
        await repository.SaveAsync();
        LoadData();
    }
    catch (Exception exc)
    {
        string errorMessage = exc.InnerException?.Message ?? exc.Message;
        await dialogService.ShowErrorAsync("Error", errorMessage);
    }
}

Some remarks on this code: This code is executed on the server, but you don’t need to think about this because you picked the @rendermode InteractiveServer.

Before I delete an artist (you always think twice before deleting the Boss), I open a dialog to ask the user if they really want to delete this brilliant artist.

Delete Dialog

This type of confirmation dialog is a built-in feature of the Fluent library. In the next step, I’ll show you how to build your own dialogs.

Additional remark: You should never, ever delete the Boss, by the way.

Edit or Add Data

If you want to add new artists to the database, you need to enter additional information like name and description. For this scenario, you may need a customized form to enter this data. Like in other frameworks, you can build a new “component” based on other components.

In Blazor, you create a new component and display it in a “dialog,” “flyout panel,” or other components.

Here is the EditArtistPanel.razor that I will use later in different kinds of dialogs:

@using ConcertDatabase.Entities
@implements IDialogContentComponent<Artist>

<FluentDialogHeader ShowDismiss="false">
    <FluentStack VerticalAlignment="VerticalAlignment.Center">
        <FluentIcon Value="@(new Icons.Regular.Size24.Delete())" />
        <FluentLabel Typo="Typography.PaneHeader">
            @Dialog.Instance.Parameters.Title
        </FluentLabel>
    </FluentStack>
</FluentDialogHeader>

<FluentTextField Label="Name" @bind-Value="@Content.Name" />
<FluentTextField Label="Description" @bind-Value="@Content.Description" />

<FluentDialogFooter>
    <FluentButton Appearance="Appearance.Accent" IconStart="@(new Icons.Regular.Size20.Save())" OnClick="@SaveAsync">Save</FluentButton>
    <FluentButton Appearance="Appearance.Neutral" OnClick="@CancelAsync">Cancel</FluentButton>
</FluentDialogFooter>

@code {

    [Parameter]
    public Artist Content { get; set; } = default!;

    [CascadingParameter]
    public FluentDialog Dialog { get; set; } = default!;

    private async Task SaveAsync()
    {
        await Dialog.CloseAsync(Content);
    }

    private async Task CancelAsync()
    {
        await Dialog.CancelAsync();
    }
}

This Razor component is quite simple. It implements the IDialogContentComponent interface, which means adding a property parameter called Content and the cascading parameter Dialog.

The Content property defines the data that is passed to the component and will also be returned when the dialog is closed. The component contains a header, a footer with save and cancel buttons, and fields for the artist’s name and description.

The code only closes the dialog and does nothing more.

Before I show you my implementation of the call to open the dialog, I want to show you two possible ways to open an editor for the artist item.

Option 1: A modal dialog that looks like a classic window

Dialog

Option 2: A flyout panel

Panel

Both methods use the exact same component, but they appear differently.

The following code shows how to call both of them:

// Open the dialog for the item
private async Task EditInDialog(Artist originalItem)
{
    var parameters = new DialogParameters
        {
            Title = "Edit Artist",
            PreventDismissOnOverlayClick = true,
            PreventScroll = true
        };

    var dialog = await dialogService.ShowDialogAsync<EditArtistPanel>(originalItem.DeepCopy(), parameters);
    var dialogResult = await dialog.Result;
    await HandleEditConcertDialogResult(dialogResult, originalItem);
}

// Open the panel for the item
private async Task EditInPanel(Artist originalItem)
{
    DialogParameters<Artist> parameters = new()
        {
            Title = $"Edit Artist",
            Alignment = HorizontalAlignment.Right,
            PrimaryAction = "Ok",
            SecondaryAction = "Cancel"
        };
    var dialog = await dialogService.ShowPanelAsync<EditArtistPanel>(originalItem.DeepCopy(), parameters);
    var dialogResult = await dialog.Result;
    await HandleEditConcertDialogResult(dialogResult, originalItem);
}

// Handle the result of the edit dialog/panel
private async Task HandleEditConcertDialogResult(DialogResult result, Artist originalItem)
{
    // If cancelled, return
    if (result.Cancelled)
    {
        return;
    }

    // If the data is not null, update the item
    if (result.Data is not null)
    {
        var updatedItem = result.Data as Artist;
        if (updatedItem is null)
        {
            return;
        }

        // Take the data from the "edited" item and put it into the original item
        originalItem.Name = updatedItem.Name;
        originalItem.Description = updatedItem.Description;

        repository.Update(originalItem);
        await repository.SaveAsync();
        LoadData();
    }
}

The function EditInDialog calls the ShowDialogAsync method of the dialogService, and EditInPanel calls the ShowPanelAsync function. Both are configured with parameters for visualization.

You may notice that I’m using a variable called dialogService. This was injected at the top of the component with @inject IDialogService dialogService. To make this work correctly, you also need to add the component <FluentDialogProvider @rendermode="RenderMode.InteractiveServer" /> in the MainLayout.razor component or where it will be required. Otherwise, the dialogs will not show up.

I have one more additional remark about the code here. I’m using this code statement: originalItem.DeepCopy(), to create a copy of an object. I’m doing this because otherwise, the dialogs would change the object instantly and not only on clicking “OK”.

I’m doing this deep copy with a quite simple extension method:

public static class ExtensionMethods
{
    public static T DeepCopy<T>(this T self)
    {
        var serialized = JsonSerializer.Serialize(self);
        var result = JsonSerializer.Deserialize<T>(serialized) ?? default!;
        return result;
    }
}

This is the simplest way to clone an object, regardless of its depth and complexity. It may not be the most efficient way, but it works for me here.

To be complete on the methods, I also want to show the add method:

private async Task AddInDialog()
{
    // Create new empty object
    Artist newItem = new();

    var parameters = new DialogParameters
        {
            Title = "Add Artist",
            PreventDismissOnOverlayClick = true,
            PreventScroll = true
        };
    // show dialog
    var dialog = await dialogService.ShowDialogAsync<EditArtistPanel>(newItem, parameters);
    var dialogResult = await dialog.Result;
    await HandleAddDialogResult(dialogResult);
}

private async Task HandleAddDialogResult(DialogResult result)
{
    if (result.Cancelled)
    {
        return;
    }

    if (result.Data is not null)
    {
        var newItem = result.Data as Artist;
        if (newItem is null)
        {
            return;
        }
        await repository.AddAsync(newItem);
        await repository.SaveAsync();
        LoadData();
    }
}

And What About Concerts

Each artist I’m tracking in my database, has concerts that I’ve visited. This is handled in the Artist Details Page.

Concerts

The implementation looks like this:

@page "/artist/{ItemID:guid}"
@using ConcertDatabase.Components.Pages.Artists.Panels
@using ConcertDatabase.Components.Pages.Concerts.Panels
@using ConcertDatabase.Entities
@using ConcertDatabase.Repositories
@inject IDialogService dialogService
@inject ArtistRepository repository
@inject NavigationManager navigationManager

@rendermode InteractiveServer

<h3>Artist Details</h3>

@if (artist != null)
{
    <FluentLabel>@artist.Name</FluentLabel>
    <FluentLabel>@artist.Description</FluentLabel>

    <FluentButton IconStart="@(new Icons.Regular.Size16.Delete())" OnClick="@(() => DeleteArtist())">Delete Artist</FluentButton>

    <FluentButton IconStart="@(new Icons.Regular.Size16.Add())" OnClick="@(() => AddConcert())">Add Concert</FluentButton>

    if (artist.Concerts != null)
    {
        <FluentDataGrid Items="@concerts" TGridItem="Concert">
            <PropertyColumn Property="@(c => c.Name)" Sortable="true" />
            <TemplateColumn Title="Date" Sortable="true">
                <FluentLabel>@context.Date?.ToShortDateString()</FluentLabel>
            </TemplateColumn>
            <PropertyColumn Property="@(c => c.Venue)" Sortable="true" />
            <PropertyColumn Property="@(c => c.City)" Sortable="true" />
            <TemplateColumn Title="Actions">
                <FluentButton IconStart="@(new Icons.Regular.Size16.DesktopEdit())" OnClick="@(() => EditInPanel(context))" />
                <FluentButton IconStart="@(new Icons.Regular.Size16.Delete())" OnClick="@(() => DeleteItem(context))" />
                <FluentButton IconStart="@(new Icons.Regular.Size16.Glasses())" OnClick="@(() => ShowConcert(context))" />
            </TemplateColumn>
        </FluentDataGrid>
    }
}
else
{
    <p><em>Loading...</em></p>
}

@code {
    [Parameter]
    public Guid ItemId { get; set; }

    Artist? artist;
    IQueryable<Concert>? concerts;

    protected override async Task OnInitializedAsync()
    {
        await LoadData();
    }

    private async Task LoadData()
    {
        artist = await repository.GetByIdWithConcerts(ItemId);
        concerts = artist?.Concerts?.AsQueryable() ?? null;
    }

    #region Data Methods

    private async Task DeleteArtist()
    {
        if (artist is null)
        {
            return;
        }

        var dialogParameters = new DialogParameters
            {
                Title = "Delete Artist",
                PreventDismissOnOverlayClick = true,
                PreventScroll = true
            };

        var dialog = await dialogService.ShowConfirmationAsync(
            "Are you sure you want to delete this artist?",
            "Yes",
            "No",
            "Delete Concert?");
        var result = await dialog.Result;
        if (!result.Cancelled)
        {
            repository.Delete(artist);
            await repository.SaveAsync();
            navigationManager.NavigateTo("/artists");
        }
    }

    #region Add

    private async Task AddConcert()
    {
        Concert newItem = new();

        var parameters = new DialogParameters
            {
                Title = "Add Concert",
                PreventDismissOnOverlayClick = true,
                PreventScroll = true
            };

        var dialog = await dialogService.ShowDialogAsync<EditConcertPanel>(newItem, parameters);
        var dialogResult = await dialog.Result;
        await HandleAddDialogResult(dialogResult);
    }

    private async Task HandleAddDialogResult(DialogResult result)
    {
        if (result.Cancelled)
        {
            return;
        }

        if (result.Data is not null)
        {
            var concert = result.Data as Concert;
            if (concert is null)
            {
                return;
            }

            if (artist is null)
            {
                return;
            }

            repository.AddConcert(artist, concert);
            await LoadData();
        }
    }

    #endregion 

    #region Edit

    private async Task EditInDialog(Concert originalItem)
    {
        var parameters = new DialogParameters
            {
                Title = "Edit Concert",
                PreventDismissOnOverlayClick = true,
                PreventScroll = true
            };

        var dialog = await dialogService.ShowDialogAsync<EditConcertPanel>(originalItem.DeepCopy(), parameters);
        var dialogResult = await dialog.Result;
        await HandleEditConcertDialogResult(dialogResult, originalItem);
    }

    private async Task EditInPanel(Concert originalItem)
    {
        DialogParameters<Concert> parameters = new()
            {
                Title = $"Edit Concert",
                Alignment = HorizontalAlignment.Right,
                PrimaryAction = "Ok",
                SecondaryAction = "Cancel"
            };
        var dialog = await dialogService.ShowPanelAsync<EditConcertPanel>(originalItem.DeepCopy(), parameters);
        var dialogResult = await dialog.Result;
        await HandleEditConcertDialogResult(dialogResult, originalItem);
    }

    private async Task HandleEditConcertDialogResult(DialogResult result, Concert originalItem)
    {
        if (result.Cancelled)
        {
            return;
        }

        if (result.Data is not null)
        {
            var concert = result.Data as Concert;
            if (concert is null)
            {
                return;
            }

            originalItem.Name = concert.Name;
            originalItem.Description = concert.Description;
            originalItem.Date = concert.Date;
            originalItem.Venue = concert.Venue;
            originalItem.City = concert.City;
            originalItem.SetList = concert.SetList;
            originalItem.Url = concert.Url;

            repository.UpdateConcert(originalItem);
            await repository.SaveAsync();
            await LoadData();
        }
    }

    #endregion

    #region Delete
    
    private async Task DeleteItem(Concert item)
    {
        if (item is null)
        {
            return;
        }

        var dialogParameters = new DialogParameters
        {
            Title = "Delete Concert",
            PreventDismissOnOverlayClick = true,
            PreventScroll = true
        };

        var dialog = await dialogService.ShowConfirmationAsync(
            "Are you sure you want to delete this concert?", 
            "Yes", 
            "No", 
            "Delete Concert?");
        var result = await dialog.Result;
        if (!result.Cancelled)
        {
            repository.DeleteConcert(item);
            await repository.SaveAsync();
            await LoadData();
        }
    }

    #endregion

    private void ShowConcert(Concert item)
    {
        navigationManager.NavigateTo($"/concert/{item.ID}");
    }

    #endregion
}

More Information

This article documents some (but not all) interesting features and my learnings with Blazor and the Fluent UI. It only took a few hours to set this up. In one of my next posts, I will describe the data infrastructure behind this solution in depth.

🤟 Stay tuned and rock on.

You can find my latest code for the concert database here: https://github.com/oliverscheer/blazor-fluent-ui-demo