Blazor .NET Core 3.1

De Banane Atomic
Révision datée du 15 août 2021 à 14:23 par Nicolas (discussion | contributions) (→‎Input CheckBox)
(diff) ← Version précédente | Voir la version actuelle (diff) | Version suivante → (diff)
Aller à la navigationAller à la recherche

Liens

Additional libraries

Blazorise components, bootstrap, font-awesome
Radzen Blazor Components components
Blazored components
BlazorStrap, Bootstrap 4 Components for Blazor Framework bootstrap
Blazor Extensions
Awesome Blazor projects
Ant Blazor Design components

Description

  • Développement frontend avec C#
  • Intégration des bibliothèques .NET existantes (nuget)

WebAssembly

Permet d'exécuter du bytecode (code intermediaire) dans le navigateur grâce à la javascript runtime sandbox.
WebAssembly est nativement présent dans les navigateurs moderne.
WebAssembly possède un runtime .NET (mono.wasm), ce qui permet d'exécuter des assemblies .NET dans le navigateur.

Créer une application

Bash.svg
# Blazor server
dotnet new blazorserver --no-https -o [project-name]
cd [project-name]
dotnet run

# Blazor WebAssembly
dotnet new -i Microsoft.AspNetCore.Blazor.Templates::3.1.0-preview4.19579.2
dotnet new blazorwasm -o blazorwasm
cd blazorwasm
dotnet run

Solution Blazor / Webapi

Bash.svg
# create item/item.sln
dotnet new sln -o item
cd item

dotnet new blazorserver --no-https -o item.blazor
dotnet new webapi --no-https -o item.webapi

dotnet sln add item.blazor/item.blazor.csproj
dotnet sln add item.webapi/item.webapi.csproj
item/.vscode/tasks.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build blazor",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/item.blazor/item.blazor.csproj",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile"
        },
        {
            "label": "build webapi",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/item.webapi/item.webapi.csproj",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile"
        },
        {
            "label": "build all",
            "dependsOn": [
                "build blazor",
                "build webapi"
            ],
            "group": "build",
            "problemMatcher": []
        }
    ]
}
item/.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "blazor",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build blazor",
            "program": "${workspaceFolder}/item.blazor/bin/Debug/netcoreapp3.1/item.blazor.dll",
            "args": [],
            "cwd": "${workspaceFolder}/item.blazor",
            "stopAtEntry": false,
            "serverReadyAction": {
                "action": "openExternally",
                "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
            },
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "sourceFileMap": {
                "/Views": "${workspaceFolder}/Views"
            }
        },
        {
            "name": "webapi",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build webapi",
            "program": "${workspaceFolder}/item.webapi/bin/Debug/netcoreapp3.1/item.webapi.dll",
            "args": [],
            "cwd": "${workspaceFolder}/item.webapi",
            "stopAtEntry": false,
            "serverReadyAction": {
                "action": "openExternally",
                "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
            },
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "sourceFileMap": {
                "/Views": "${workspaceFolder}/Views"
            }
        }
    ],
    "compounds": [
        {
            "name": "blazor / webapi",
            "configurations": [
                "blazor",
                "webapi"
            ],
            "preLaunchTask": "build all"
        }
    ]
}

Hosting models

Blazor WebAssembly

L'application est téléchargée et exécutée dans le navigateur.

  • Excellentes performances
  • Pas besoin d'un serveur web ASP.NET Core pour héberger l'application
  • Permet une utilisation offline une fois chargée
  • Le temps de chargement est long, il faut télécharger l'application, ses dépendances et le .NET runtime
  • Les identifiants pour les accès aux bdd et web API sont chargés par chaque client

Blazor Server

L'application est exécutée sur le serveur. UI updates, event handling et les appels JavaScript sont pris en charge grâce à une connexion SignalR.

  • Pas besoin de WebAssembly
  • Chaque interaction utilisateur nécessite un appel au serveur
  • Un serveur web ASP.NET Core est nécessaire pour héberger l'application

Blazor server application

C'est une ASP.NET Core application.

Arborescence de fichiers

  • Program.cs: point d'entrée, appelle Startup.cs
  • Startup.cs: configuration (DI)
  • Pages: Components
  • Shared: shared Components comme les layouts (main, menu)
  • App.razor: routing
  • _Imports.razor: import namespaces
  • wwwroot: static files (css, js)
  • Data: service to access data

Debug

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    // avoir des erreurs détaillées
    services.AddServerSideBlazor()
            .AddCircuitOptions(options => { options.DetailedErrors = true; });

Code-behind

Code in the blazor page

MyPage.razor
@page "/mypage"

@inject IDataService DataService

<h1>Title</h1>

@code {
    private Data data;
    // some C# code
}

Code in a partial class

MyPage.razor
@page "/mypage"

<h1>Title</h1>
MyPage.razor.cs
public partial class MyPage : ComponentBase
{
    [Inject]
    private IDataService DataService { get; set; }

    private Data data;
    // some C# code
}

Code in an inherited class

MyPage.razor
@page "/mypage"
@inherits MyPageBase

<h1>Title</h1>
MyPageBase.cs
public class MyPageBase : ComponentBase
{
    [Inject]
    private IDataService DataService { get; set; }

    protected Data data;
    // some C# code
}

Pages

Pages/Items.razor
@* url *@
@page "/items"
@* url avec un parameter *@
@page "/item/{id:int}"

@* attendre que la valeur soit chargée *@
@if (Items == null)
{
    <p><em>Loading...</em></p>
}
else
{ /* ... */ }

@code {
    [Parameter]
    public int Id { get; set; }
}

Components

Components/MyComponent.razor

Components/MyComponentBase.cs
[Parameter]
public EventCallback<bool> MyEventCallback { get; set; }

// invoke asynchronously callback
await MyEventCallback.InvokeAsync(true);
_Imports.razor
@* Make components available *@
@using BlazorServerApp.Components
Pages/MyPage.razor
<MyComponent @ref="MyComponent"
             MyEventCallback="@MyComponent_OnSomethingHappened"></MyComponent>
Pages/MyPageBase.cs
protected MyComponentBase MyComponent { get; set; }

public async void MyComponent_OnSomethingHappened()
{
    // refresh the DOM if elements have changed
    StateHasChanged();
}

Lifecycle methods

  1. OnInitialized OnInitializedAsync
  2. OnParametersSet OnParametersSetAsync
  3. OnAfterRender OnAfterRenderAsync

Layout

App.razor
<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        @* définit le layout par défaut *@
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>
Shared/MainLayout.razor
@inherits LayoutComponentBase

<div class="sidebar">
    <NavMenu />
</div>

<div class="main">
    <div class="content px-4">
        @Body
    </div>
</div>

Data Service

Dependency Injection

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    // configuration du container de DI
    services.AddHttpClient<IItemDataService, ItemDataService>(
        client => client.BaseAddress = new Uri("http://localhost:5002")
    );

Data Service

Services/IItemDataService.cs
public interface IItemDataService
{
    Task<IEnumerable<Item>> GetAllItemsAsync();
    Task<Item> GetItemAsync(int itemId);
    Task UpdateItemAsync(Item item);
    Task DeleteItemAsync(int itemId);
    Task<Item> AddItemAsync(Item item);
}
Services/ItemDataService.cs
public class ItemDataService : IItemDataService
{
    private readonly HttpClient _httpClient;

    // injection de HttpClient
    public ItemDataService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<IEnumerable<Item>> GetAllItemsAsync()
    {
        return await JsonSerializer.DeserializeAsync<IEnumerable<Item>>(
            await _httpClient.GetStreamAsync($"api/item"),
            new JsonSerializerOptions()
            {
                PropertyNameCaseInsensitive = true
            });
    }

    public async Task<Item> GetItemAsync(int itemId)
    {
        return await JsonSerializer.DeserializeAsync<Item>(
            await _httpClient.GetStreamAsync($"api/item/{itemId}"),
            new JsonSerializerOptions()
            {
                PropertyNameCaseInsensitive = true
            });
    }

    public async Task UpdateItemAsync(Item item)
    {
        var itemJson = new StringContent(
            JsonSerializer.Serialize(item),
            Encoding.UTF8,
            "application/json");

        await _httpClient.PutAsync("api/item", itemJson);
    }

    public async Task DeleteItemAsync(int itemId)
    {
        await _httpClient.DeleteAsync($"item/{itemId}");
    }

    public async Task<Item> AddItemAsync(Item item)
    {
        var itemJson = new StringContent(
            JsonSerializer.Serialize(item),
            Encoding.UTF8,
            "application/json");

        var response = await _httpClient.PostAsync("item", itemJson);

        if (response.IsSuccessStatusCode)
        {
            return await JsonSerializer.DeserializeAsync<Item>(
                await response.Content.ReadAsStreamAsync());
        }

        return null;
    }

Component

Pages/MyComponent.razor.cs
public partial class MyComponent : ComponentBase
{
    // injection de IItemDataService (cette syntaxe n'est possible que dans les components)
    [Inject]
    private IItemDataService ItemDataService { get; set; }

    private IEnumerable<Item> Items { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Items = await ItemDataService.GetAllItemsAsync();
    }

Data binding

Fichier:Blazor.svg
<input type="date" @bind="@SelectedDate" />
<p class="@(IsImportant ? "text-alert" : "")">Text</p>

@code {
    DateTime SelectedDate { get; set; }
    bool IsImportant { get; set; }
}

Event handling

Fichier:Blazor.svg
<input type="date" @oninput="@OnValueChangedAsync" />

@code {
    async Task OnValueChangedAsync(ChangeEventArgs args)
    {
        var selectedDate = DateTime.Parse(args.Value.ToString());
        await MyAsyncCall(selectedDate);
    }
}

Exception handling

Blazor treats most unhandled exceptions as fatal to the circuit where they occur.
If a circuit is terminated due to an unhandled exception, the user can only continue to interact with the app by reloading the page to create a new circuit.
There is no way to handle unhandled exceptions. Each exception must be catched and handled.

Form

EditItem.razor
@page "/edititem/{ItemId:int}"
@* to access to Model.Item *@
@using Model

<EditForm Model="@item"
          OnValidSubmit="@HandleValidSubmitAsync"
          OnInvalidSubmit="@HandleInvalidSubmitAsync">

    <button type="submit">Save</button> 
    <button @onclick="Cancel">Cancel</button>           
</EditForm>
EditItem.razor.cs
[Parameter]
public int ItemId { get; set; }

[Inject]
private IItemDataService ItemDataService { get; set; }

private Item item = new Item();

protected override async Task OnInitializedAsync()
{
    item = await ItemDataService.GetItemAsync(ItemId);
}

// when the submit button is clicked and the model is valid
private async Task HandleValidSubmitAsync()
{
    await ItemDataService.UpdateItemAsync(item);
}

// when the submit button is clicked and the model is not valid
private async Task HandleInvalidSubmitAsync()
{

}

Input Text

Razor.svg
<div class="form-group row">

    <label for="name"
           class="col-sm-3">
        Item: 
    </label>

    <InputText id="name"
               @bind-Value="Item.Name"
               class="form-control col-sm-8"
               placeholder="Enter name">
    </InputText>
</div>

Input Text Area

Razor.svg
<div class="form-group row">
    <label for="comment"
           class="col-sm-3">
        Comment: 
    </label>
    <InputTextArea id="comment"
                   class="form-control col-sm-8"
                   @bind-Value="Item.Comment"
                   placeholder="Enter comment">
    </InputTextArea>
</div>

Input Number

Razor.svg
<div class="form-group row">

    <label for="price"
           class="col-sm-3">Price: </label>

    <InputNumber id="price"
                 @bind-Value="@Item.Price"
                 class="form-control col-sm-8">
    </InputNumber>
</div>

Input Date

Razor.svg
<div class="form-group row">

    <label for="date"
           class="col-sm-3">
        Date: 
    </label>

    <InputDate id="date"
               class="form-control col-sm-8"
               @bind-Value="Item.Date"
               placeholder="Enter date">
    </InputDate>
</div>

Input Select

Only support binding of type string.
Razor.svg
<div class="form-group row">
    <label for="country" class="col-sm-3">Color: </label>
    <InputSelect id="color"
                 @bind-Value="CountryId"
                 class="form-control col-sm-8">
        @foreach (var color in Enum.GetValues(typeof(RgbColor)))
        {
            <option value="@color">@color</option>
        }
        <option value="@RgbColor.Red">@RgbColor.Red</option>
        <option value="@RgbColor.Green">@RgbColor.Green</option>
        <option value="@RgbColor.Blue">@RgbColor.Blue</option>
    </InputSelect>
</div>

Build your custom InputSelect to handle binding with int

Components/DropDown.cs
public sealed class DropDown<T> : InputSelect<T>
{
    protected override bool TryParseValueFromString(string value, out T result, out string validationErrorMessage)
    {
        if (typeof(T) == typeof(string))
        {
            result = (T)(object)value;
            validationErrorMessage = null;

            return true;
        }
        else if (typeof(T) == typeof(int))
        {
            int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue);
            result = (T)(object)parsedValue;
            validationErrorMessage = null;

            return true;
        }
        else if (typeof(T).IsEnum)
        {
            try
            {
                result = (T)Enum.Parse(typeof(T), value);
                validationErrorMessage = null;

                return true;
            }
            catch (ArgumentException)
            {
                result = default;
                validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid.";

                return false;
            }
        }

        throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(T)}'.");
    }
}
_Imports.razor
@using MyNamespace.Components

Error InputSelect`1[System.Int32] does not support the type 'System.Int32'

To bind to an int, use select instead of InputSelect:

Razor.svg
<div class="form-group row">
    <label for="group" class="col-sm-3">Color: </label>
    <select id="group"
            @bind="@User.Group.Id"
            class="form-control col-sm-8">
        @foreach (var availableGroup in AvailableGroups)
        {
            <option value="@availableGroup.Id">@availableGroup.Name</option>
        }
    </select>
</div>

Input CheckBox

InputCheckbox requires a cascading parameter of type EditContext.
For example, you can use InputCheckbox inside an EditForm.
Razor.svg
<div class="form-group row">
    <label for="smoker"
           class=" offset-sm-3">
        <InputCheckbox id="smoker"
                       @bind-Value="Employee.Smoker">
        </InputCheckbox>
        &nbsp;Smoker
    </label>
</div>
Razor.svg
<label>
    <input type="checkbox" @onchange="(e) => FilterAsync(e)" />
    &nbsp;Only items with ...
</label>

@code {
    private async Task FilterAsync(ChangeEventArgs e)
    {
        if ((bool)e.Value)
        {
            Items = await this.ItemClient.GetAsync();
        }
        else
        {
            Items = await this.ItemClient.GetAsync();
        }
    }
}

InputFile

Bash.svg
dotnet add package BlazorInputFile
Pages/_Host.cshtml
    <script src="_content/BlazorInputFile/inputfile.js"></script>
</body>
_Imports.razor
@using BlazorInputFile
Fichier:Blazor.svg
<InputFile OnChange="HandleSelectionAsync" />
<p>@OutputMessages</p>

@code {
    async Task HandleSelectionAsync(IFileListEntry[] files)
    {
        var file = files.FirstOrDefault();
        if (file != null)
        {
            OutputMessages += $"Loading {file.Name}";

            string jsonContent;
            using (var streamReader = new StreamReader(file.Data))
            {
                jsonContent = await streamReader.ReadToEndAsync();
            }

            var importedItems = JsonConvert.DeserializeObject<IReadOnlyCollection<ItemDto>>(jsonContent);
            OutputMessages += $"<br>{importedItems.Count} items extracted";
        }
    }
}

Align multiple inputs

Razor.svg
<EditForm Model="@myModel">
    <table>
        <tbody>
            <tr>
                <td><label for="login">Login: </label></td>
                <td>
                    <InputText id="login"
                            @bind-Value="myModel.Login"
                            class="form-control"
                            placeholder="Lgin">
                    </InputText>
                </td>
            </tr>
            <tr>
                <td><label for="password">Password: </label></td>
                <td>
                    <InputText id="password"
                            @bind-Value="myModel.Password"
                            class="form-control"
                            placeholder="Password">
                    </InputText>
                </td>
            </tr>
        </tbody>
    </table>

    <div class="mt-3">
        <button class="btn btn-primary btn-sm" @onclick="Save">Save</button>
        <button class="btn btn-secondary btn-sm" @onclick="Cancel">Cancel</button>
    </div>

</EditForm>

Form validation

Item.cs
// nécessite le package nuget System.ComponentModel.Annotations
[Required]
[StringLength(50, ErrorMessage = "Name too long (max 50 char)")]
public string Name { get; set; }
Pages/ItemEdit.razor
<EditForm Model="@Item">
    <DataAnnotationsValidator />
    @* affiche la liste des messages d'erreur de validation *@
    <ValidationSummary />

    <div class="form-group row">
        <label for="name" class="col-sm-3">Name: </label>
        <InputText id="name" @bind-value="Item.Name" class="form-control col-sm-8" placeholder="Enter name"></InputText>>
        @* affiche le message d'erreur de validation *@
        <ValidationMessage class="offset-sm-3 col-sm-8" For="@(() => Item.Name)" />
    </div>

    <button type="submit">Save</button>
    <button type="button" @onclick="Cancel">Cancel</button>

Fluent validation on client

Bash.svg
dotnet add package Blazored.FluentValidation
_Imports.razor
@using Blazored.FluentValidation
Pages/EditItem.razor
<EditForm Model="@Item">
    <FluentValidationValidator />
    <ValidationSummary />

Finding Validators

By default, the component will check for validators registered with DI first.
If it can't find any, it will then try scanning the applications assemblies to find validators using reflection.
Use DisableAssemblyScanning to load validators from DI only.

Xaml.svg
<FluentValidationValidator DisableAssemblyScanning="@true" />
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddTransient<IValidator<ItemDto>, ItemDtoValidator>();

Display server validation errors

BusinessLogicValidator.cs
public class BusinessLogicValidator : ComponentBase
{
    private ValidationMessageStore messageStore;

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

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

        messageStore = new ValidationMessageStore(CurrentEditContext);

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

    public void DisplayErrors(IDictionary<string, string[]> errors)
    {
        foreach (var err in errors)
        {
            messageStore.Add(CurrentEditContext.Field(err.Key), err.Value);
        }

        CurrentEditContext.NotifyValidationStateChanged();
    }

    public void ClearErrors()
    {
        messageStore.Clear();
        CurrentEditContext.NotifyValidationStateChanged();
    }
}
ItemEdit.razor
<EditForm Model="@item"
          OnValidSubmit="@HandleValidSubmitAsync">

    <BusinessLogicValidator @ref="businessLogicValidator" />
ItemEdit.razor.cs
private async Task HandleValidSubmitAsync()
{
    businessLogicValidator.ClearErrors();

    var result = await ItemClient.CreateAsync(itemViewModel);

    if (!result.IsSuccess)
    {
        businessLogicValidator.DisplayErrors(result.Errors);
    }
CallResult.cs
public sealed class CallResult
{
    public string SuccessMessage { get; }
    public IDictionary<string, string[]> Errors { get; }
    public bool IsSuccess => Errors.Count == 0;

    public CallResult(IDictionary<string, string[]> errors, string successMessage)
    {
        this.SuccessMessage = successMessage;
        this.Errors = errors;
    }
}
ItemClient.cs
public async Task<CallResult> CreateAsync(ItemViewModel itemViewModel)
{
    // ...
    var response = await this.httpClient.PostAsync(uri, createItemDtoJson);
    var errors = await ExtractErrorsAsync(response);

    // ...

    return new CallResult(errors, successMessage);
}

private async Task<IDictionary<string, string[]>> ExtractErrorsAsync(HttpResponseMessage response)
{
    IDictionary<string, string[]> errors = new Dictionary<string, string[]>();

    if (!response.IsSuccessStatusCode)
    {
        switch (response.StatusCode)
        {
            case HttpStatusCode.BadRequest:
                var content = await response.Content.ReadAsStreamAsync();
                var validationProblemDetails =
                    await JsonSerializer.DeserializeAsync<ValidationProblemDetails>(content);
                errors = validationProblemDetails.Errors;
                break;
            case HttpStatusCode.NotFound:
                var id = await response.Content.ReadAsAsync<int>();
                errors.Add("", new[] { $"Item id {id} not found." });
                break;
            case HttpStatusCode.InternalServerError:
                errors.Add("", new[] { "Internal server error. Expense has not been added." });
                break;
            default:
                errors.Add("", new[] { "Unknown error. Item has not been added." });
                break;
        }
    }

    return errors;
}

Navigation

MyPage.razor
<a @onclick="@NavigateToHome">Home</a>
<a @onclick="@(() => NavigateToEdit(item.Id)">Home</a>
MyPageBase.cs
[Inject]
public NavigationManager NavigationManager { get; set; }

protected void NavigateToHome()
{
    NavigationManager.NavigateTo("home");
}

protected void NavigateToEdit(int itemId)
{
    NavigationManager.NavigateTo($"edit/{itemId}");
}

Cascading values and parameters

Static files

Pages/_Host.cshtml
<head>
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
wwwroot/css/site.css
/* custom css */

/* import Iconic font */
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
  • wwwroot/favicon.ico

Font-Awesome

  • wwwroot/css/font-awesome
    • css/all.min.css
    • webfonts/*
    • LICENSE.txt
wwwroot/css/site.css
@import url('font-awesome/css/all.min.css');

Pagination

Fichier:Blazor.svg
<a @onclick="@PreviousPage" class="@(isPreviousPageAllowed ? "" : "disabled")">
    <i class="fa fa-caret-square-left"></i>
</a>
<a @onclick="@NextPage" class="@(isNextPageAllowed ? "" : "disabled")">
    <i class="fa fa-caret-square-right"></i>
</a>
Page @currentPageIndex

@code {
    var currentPageIndex = 1;
    var isPreviousPageAllowed => currentPageIndex > 1;
    var isNextPageAllowed => AuditTrailLogs.Count == 10;

    async Task PreviousPage()
    {
        if (IsPreviousPageAllowed)
        {
            Items = await this.ItemClient.GetAsync(--currentPageIndex);
        }
    }

    async Task NextPage()
    {
        if (IsNextPageAllowed)
        {
            Items = await this.ItemClient.GetAsync(++currentPageIndex);
        }
    }
}

Toast message

Bash.svg
dotnet add package Blazored.Toast
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddBlazoredToast();
_Imports.razor
@using Blazored.Toast
@using Blazored.Toast.Services
Pages/_Host.cshtml
<head>
    <link href="_content/Blazored.Toast/blazored-toast.min.css" rel="stylesheet" />
Shared/MainLayout.razor
@using Blazored.Toast.Configuration

@*
Position (Default: ToastPosition.TopRight)
Timeout (Default: 5)
IconType (Default: IconType.FontAwesome)
SuccessClass: add css class to success toast
SuccessIcon: add css class to success toast icon
ErrorIcon: add css class to error toast icon
*@
<BlazoredToasts Position="ToastPosition.BottomRight"
                Timeout="10"
                IconType="IconType.FontAwesome"
                SuccessClass="success-toast-override"
                SuccessIcon="fa fa-thumbs-up"
                ErrorIcon="fa fa-bug" />
Pages/MyPageBase.cs
[Inject]
public IToastService ToastService { get; set; }

this.ToastService.ShowSuccess("Success message", "Success title");
this.ToastService.ShowError("Error message", "Error title");

Modal popup

Installation

Bash.svg
dotnet add package Blazored.Modal
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddBlazoredModal();
_Imports.razor
@using Blazored.Modal
@using Blazored.Modal.Services
App.razor
<CascadingBlazoredModal>
    <Router AppAssembly="typeof(Program).Assembly">
        @* ... *@
    </Router>
</CascadingBlazoredModal>
Pages/_Host.cshtml
<link rel="stylesheet" href="_content/Blazored.Modal/blazored-modal.css" />
<link rel="stylesheet" href="css/themes/blazored-modal-dark-theme.css" />

<script src="_content/Blazored.Modal/blazored.modal.js"></script>
css/themes/blazored-modal-dark-theme.css
.blazored-modal {
    background-color: rgb(68, 68, 68);
    border: 1px solid black;
    box-shadow: 0 2px 2px rgba(0, 0, 0, .25);
}

Usage

Pages/MyPageBase.cs
[CascadingParameter]
private IModalService Modal { get; set; }

parameters.Add("MyProperty", value);
Modal.Show<MyPopup>("Title", parameters);
Pages/MyPopup.razor
@inherits MyPopupBase

<div>
    @MyProperty
</div>
Pages/MyPopupBase.cs
public class MyPopupBase : ComponentBase
{
    [Parameter]
    public string MyProperty { get; set; }
}

ChartJs.Blazor v1.1

Bash.svg
dotnet add package ChartJs.Blazor
Pages/_Hosts.cshtml
<head>
    <link rel="stylesheet" href="_content/ChartJs.Blazor/ChartJSBlazor.css" />

<body>
    <script src="_content/ChartJs.Blazor/moment-with-locales.min.js" type="text/javascript" language="javascript"></script>
    <script src="_content/ChartJs.Blazor/Chart.min.js" type="text/javascript" language="javascript"></script>
    <script src="_content/ChartJs.Blazor/ChartJsBlazorInterop.js" type="text/javascript" language="javascript"></script>
_Imports.razor
@using ChartJs.Blazor;
MyBarChart.razor
@page "/my-bar-chart"
@inherits MyBarChartBase

@using ChartJs.Blazor.Charts
@using ChartJs.Blazor.ChartJS.PieChart
@using ChartJs.Blazor.ChartJS.Common.Properties
@using ChartJs.Blazor.Util

<ChartJsPieChart @ref="chart" Config="@config" Width="600" Height="300" />
MyBarChartBase.cs
public class MyChartBase : ComponentBase
{
    protected BarConfig config;
    protected ChartJsBarChart chart;

    protected override void OnInitialized()
    {
        config = new BarConfig
        {
            Options = new BarOptions
            {
                Title = new OptionsTitle
                {
                    Display = true,
                    Text = "Sample chart from Blazor",
                    FontColor = "white"
                },
                Legend = new Legend
                {
                    Labels = new LegendLabelConfiguration
                    {
                        FontColor = "white"
                    }
                },
                Scales = new BarScales
                {
                    XAxes = new List<CartesianAxis>
                    {
                        new BarCategoryAxis
                        {
                            Ticks = new CategoryTicks
                            {
                                FontColor = "white"
                            },
                            GridLines = new GridLines
                            {
                                Display = false
                            }
                        }
                    },
                    YAxes = new List<CartesianAxis>
                    {
                        new BarLinearCartesianAxis
                        {
                            Ticks = new LinearCartesianTicks
                            {
                                FontColor = "white"
                            },
                            GridLines = new GridLines
                            {
                                Color = "DimGray"
                            }
                        }
                    }
                }
            }
        };
    }

    protected override Task OnInitializedAsync()
    {
        var data = await DataClient.GetAsync();

        var dataset = new BarDataset<DoubleWrapper>
        {
            Label = "Amounts",
            BackgroundColor = Enumerable.Repeat(ColorUtil.RandomColorString(), data.Count).ToArray(),
            BorderWidth = 0, // default value
            BorderColor = "#ffffff",
            HoverBackgroundColor = ColorUtil.FromDrawingColor(Color.DodgerBlue),
            HoverBorderColor = ColorUtil.RandomColorString(),
            HoverBorderWidth = 1
        };

        var amounts = data.Select(x => (double)x.Amount);
        dataset.AddRange(amounts.Wrap());

        var labels = data.Select(x => x.Month.ToString("MMM yyyy", CultureInfo.CurrentCulture));

        config.Data.Labels.AddRange(labels);
        config.Data.Datasets.Add(dataset);

        await chart.Update();
    }
}

Javascript interop

Call JS from .NET

Pages/MyPageBase.cs
[Inject]
public IJSRuntime JSRuntime { get; set; }

await JSRuntime.InvokeVoidAsync("MyJSMethod", "arg1");
var result = await JSRuntime.InvokeAsync<string>("AddExclamationPoint", "arg1");
wwwroot/js/site.js
window.MyJsMethod = function(arg1) {
    alert(arg1);
}

window.AddExclamationPoint = function(arg1) {
    return arg1 + ' !';
}
Pages/_Host.cshtml
<body>
    @* à la fin de body *@
    <script src="js/site.js"></script>
</body>

Call .NET from JS

wwwroot/js/site.js
window.UpdateButtonContent = function (dotnetHelper, newContent) {
    dotnetHelper.invokeMethodAsync('UpdateButtonContent', newContent);
    dotnetHelper.dispose();
};
Pages/MyPageBase.cs
var objRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("UpdateButtonContentJS", objRef, newContent);
objRef.Dispose();

[JSInvokable]
public void UpdateButtonContent(string newContent)
{
    ButtonContent = newContent;
}
Pages/_Host.cshtml
<body>
    @* à la fin de body *@
    <script src="js/myscript.js"></script>
</body>

Localization / Langue / Culture

Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
{
    var supportedCultures = new[] { "fr-FR" };
    var localizationOptions = new RequestLocalizationOptions().SetDefaultCulture(supportedCultures[0])
        .AddSupportedCultures(supportedCultures)
        .AddSupportedUICultures(supportedCultures);

Multi lines text

Fichier:Blazor.svg
<p style="white-space: pre-wrap" >@message</p>

@code {
    string message = "Line 1\nLine 2";
}

Sorting table

MyTable.razor
<table class="table table-striped">
    <thead>
        <tr>
            <th class="text-center sortable-th" @onclick="@(() => SortItems("Description"))">
                Description <span class="fa @(GetSortIconName("Description"))"></span>
            </th>
MyTableBase.cs
private string sortedColumnName;
private bool isSortedAscending;

protected void SortItems(string propertyName)
{
    var sortInAscendingOrder = propertyName != sortedColumnName ? false : !isSortedAscending;

    Items = OrderBy(Items, propertyName, sortInAscendingOrder);

    isSortedAscending = sortInAscendingOrder;
    sortedColumnName = propertyName;
}

protected string GetSortIconName(string propertyName)
    => sortedColumnName != propertyName ?
        string.Empty :
        isSortedAscending ? "fa-long-arrow-alt-down" : "fa-long-arrow-alt-up";

private static IEnumerable<T> OrderBy<T>(
    IEnumerable<T> source,
    string propertyName,
    bool ascendingOrder)
{
    var propertyDescriptor = TypeDescriptor.GetProperties(typeof(T))
                                           .Find(propertyName, false);

    return ascendingOrder ?
        source.OrderBy(x => propertyDescriptor.GetValue(x)) :
        source.OrderByDescending(x => propertyDescriptor.GetValue(x));
}
Css.svg
th.sortable-th {
    cursor:pointer;
}

Handle errors

  • Unhandle exceptions crash the application ans there is no way to catch them.
  • Unhandle exceptions are logged in the journal.

Get caller ip address

Get the ip in the Host then pass it as a cascading value

This is a workaround so it works with kestrel and apache as a reverse proxy
Pages/_Host.cshtml.cs
public class HostModel : PageModel
{
    public string IPAddress { get; set; }

    public void OnGet()
    {
        this.IPAddress = this.HttpContext.Connection.RemoteIpAddress.ToString();
    }
}
Pages/_Host.cshtml
@model HostModel

<body>
    <app>
        <component type="typeof(App)" render-mode="ServerPrerendered" param-IPAddress="@Model.IPAddress" />
    </app>
App.razor
<CascadingBlazoredModal>
    <CascadingValue Name="IPAddress" Value="@IPAddress">
        <Router AppAssembly="@typeof(Program).Assembly">

@code
{
    [Parameter]
    public string IPAddress { get; set; }
}
MyPage.razor.cs
public partial class MyPage : ComponentBase
{
    [CascadingParameter(Name = "IPAddress")]
    public string IPAddress { get; set; }

Get caller remote ip

httpContextAccessor.HttpContext is null when deployed behind a reverse proxy (kestrel + apache).
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpContextAccessor();
MyPage.razor.cs
[Inject]
public IHttpContextAccessor httpContextAccessor { get; set; }

protected override void OnInitialized()
{
    var userIp = httpContextAccessor.HttpContext.Connection?.RemoteIpAddress.ToString();

Log

appsettings.json
// display detailed exception message in the browser console
"DetailedErrors": true
MyPage.razor.cs
[Inject]
ILogger<MyPage> logger { get; set; }

// log into the journal
logger.LogWarning("Warn message");
MyPage.razor.cs
[Inject]
private IJSRuntime JsRuntime { get; set; }

// log into the browser console log
await JsRuntime.InvokeAsync<string>("console.log", "Message !");

Authentication and authorization

App.razor
@* replace RouteView by AuthorizeRouteView *@
<AuthorizeRouteView RouteData="@routeData" 
                    DefaultLayout="@typeof(MainLayout)" />
Shared/LoginDisplay.razor
<AuthorizeView>
    <Authorized>
        <p>@context.User.Identity.Name</p>
    </Authorized>
    <NotAuthorized>
        <a href="login">Log in</a>
    </NotAuthorized>
</AuthorizeView>
Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints => {});
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
            .AddEntityFrameworkStores<ApplicationDbContext>();

    services.AddRazorPages();
    services.AddServerSideBlazor();
}

Scaffold Identity

Bash.svg
dotnet tool install -g dotnet-aspnet-codegenerator

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design --version 3.1.3
dotnet add package Microsoft.EntityFrameworkCore.Design --version 3.1.3
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 3.1.3
dotnet add package Microsoft.AspNetCore.Identity.UI --version 3.1.3
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 3.1.3
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 3.1.3

# help
dotnet aspnet-codegenerator identity -h

# list the files that can be scaffolded
dotnet aspnet-codegenerator identity -lf

# scaffold
dotnet aspnet-codegenerator identity -dc MyApplication.Data.ApplicationDbContext --files "Account.Login"
# create the page Area/Identity/Pages/Account/Login.cshtml

Authenticate with web api

Cookies x