« Blazor ASP.NET Core 7.0 » : différence entre les versions

De Banane Atomic
Aller à la navigationAller à la recherche
Ligne 418 : Ligne 418 :
}
}
</kode>
</kode>
* [https://stackoverflow.com/questions/58076758/navigationerror-on-navigateto NavigationException with NavigationManager called from OnInitialized]


= [https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-7.0 Dependency injection] =
= [https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-7.0 Dependency injection] =

Version du 30 juillet 2023 à 14:20

Links

Components library

Pages

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

<PageTitle>Items</PageTitle>

@if (Items == null)  @* attendre que la valeur soit chargée *@
{
    <p><em>Loading...</em></p>
}
else
{ /* ... */ }
Pages/Items.razor.cs
public partial class Table : ComponentBase
{
    private IReadOnlyCollection<ItemDto> items;

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

    protected override async Task OnInitializedAsync()
    {
        items = await this.ItemClient.GetAsync();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender) { }

CSS isolation

Pages/Items.razor.css
/* scoped css only for this page */
/* @import is not allowed in scoped css */

JS isolation

Pages/MyPage.razor.js
export function myFunction() { }

export function myFunctionWithArg(arg) {
    return 'ok';
}
Pages/MyPage.razor.cs
public partial class MyPage : ComponentBase, IAsyncDisposable
{
    [Inject]
    private IJSRuntime JS { get; set; } = default!;

    private IJSObjectReference? module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSObjectReference>("import", "./Pages/MyPage.razor.js");
        }
    }

    private async Task MyJsFunction()
    {
        if (module is not null)
            await module.InvokeVoidAsync("myFunction");
    }

    private async ValueTask<string?> MyJsFunctionWithArg(string arg)
    {
        string result = null;
        if (module is not null) 
            await module.InvokeAsync<string>("myFunctionWithArg", arg);
        return result;
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is not null)
            await module.DisposeAsync();
    }

Event handling

MyPage.razor
<button @onclick=OnButtonClicked>Click me</button>
<button @onclick="() => OnButtonClicked(Item.Name)">Click me</button>
MyPage.razor.cs
void OnButtonClicked()
{ }

async Task OnButtonClicked(string name)
{ }

preventDefault and stopPropagation

Razor.svg
<div @onclick="OnDivClicked">
    <a href="/page1" @onclick=OnLinkClicked
                     @onclick:preventDefault    @* prevent the navigation to the url but still executes the OnLinkClicked method *@
                     @onclick:stopPropagation>  @* stop the propagation of the event (bubbling), so the OnDivClicked method is never called *@
        Page 1
    </a>
</div>

Form

EditItem.razor
@page "/edititem/{ItemId:int}"

<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 IItemService ItemService { get; set; }

private Item item = null;

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 ItemService.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>

Data binding

One way

Changes made in the UI doesn't change the binded property.
The onchange event can be used to manually update the property.

Fichier:Blazor.svg
<input type="text" value="@Description"
       @onchange=@((ChangeEventArgs __e) => Description = __e.Value.ToString())
       @onchange=OnInputChanged />

@code {
    string Description { get; set; } = "Text";

    void OnInputChanged(ChangeEventArgs e) => Description = e.Value.ToString();
}

Two way

Fichier:Blazor.svg
<input type="text" @bind="Name" />    @* by default uses the 'on change' event (focus lost) *@

<input type="text" @bind="Name"
                   @bind:event="oninput"          @* use the oninput event instead of the default onchange, the event is triggered on each input key event *@
                   @bind:after="DoActionAsync" /> @* trigger the DoActionAsync method when the UI text is modified *@

@code {
    string Name { get; set; } = "Text";

    async Task DoActionAsync() { }
}

Form validation

Data Annotation

Item.cs
[Required, StringLength(50, MinimumLength = 3, ErrorMessage = "Name must be between 3 and 50 characters")]
public string Name { get; set; }
Pages/ItemEdit.razor
<EditForm Model="@item">
    <DataAnnotationsValidator />
    <ValidationSummary />  @* affiche la liste des messages d'erreur de validation *@

    <InputText @bind-value="item.Name" 
               placeholder="Enter name" />
    <ValidationMessage class="offset-sm-3 col-sm-8" For="@(() => Item.Name)" />  @* affiche le message d'erreur de validation *@

FluentValidation

Bash.svg
dotnet add package Blazored.FluentValidation
_Imports.razor
@using Blazored.FluentValidation
Pages/MyForm.razor
<EditForm Model="@model" OnValidSubmit="@SubmitValidForm">
    <FluentValidationValidator />

Manual validation

Clients/ItemClient.cs
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetItemsAsync(
    ItemQuery query,
    CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(query);

    // client validation
    var validator = new ItemQueryValidator();
    var validationResult = validator.Validate(query);
    if (!validationResult.IsValid)
    {
        return new OperationResult<IReadOnlyCollection<TransactionResponse>>(validationResult.Errors);
    }
Clients/OperationResult.cs
public class OperationResult<T>
{
    public T? Result { get; set; }
    public IDictionary<string, string[]> ValidationErrors { get; } // error messages grouped by property name
    public IReadOnlyCollection<string> Errors => ValidationErrors.SelectMany(x => x.Value).ToList();
    public bool IsValid => ValidationErrors.Count == 0;

    public OperationResult(IDictionary<string, string[]> validationErrors)  // for server side validation error
    {
        ValidationErrors = validationErrors;
    }

    public OperationResult(IEnumerable<ValidationFailure> validationFailures)  // for client side validation errors
    {
        ValidationErrors = validationFailures
            .GroupBy(x => x.PropertyName, x => x.ErrorMessage)
            .ToDictionary(x => x.Key, x => x.ToArray());
    }

    public OperationResult(T result)  // for valid result
    {
        Result = result;
        ValidationErrors = new Dictionary<string, string[]>();
    }
}

Form exemples

Deactivate the form while submitting

MyPage.razor
<EditForm Model="@search"
          OnValidSubmit="@HandleValidSubmitAsync">
    <DataAnnotationsValidator />

    <fieldset disabled="@isSearching">
        <div class="row justify-content-center">
            <InputText @bind-Value="search.Text"
                        class="col-sm-6"
                        placeholder="Nom de table" />
            <button type="submit" class="btn btn-primary col-auto">
                @if (!isSearching)
                {
                    <span class="oi oi-magnifying-glass" aria-hidden="true"></span>
                }
                else
                {
                    <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
                }
            </button>
        </div>

        <div class="row justify-content-center">
            <ValidationMessage class="text-danger col-sm-6" For="@(() => search.Text)" />
        </div>
    </fieldset>

</EditForm>

@if (isSearching)
{
    <h5 class="text-center mt-5 mb-2">Loading 
        <span class="spinner-border spinner-border-sm" role="status"></span>
    </h5>
}
else if (tables.Count > 0)
{
    <h5 class="text-center mt-5 mb-2">Résultat de la recherche</h5>
}
else if (validSearchHasBeenStarted)
{
    <h5 class="text-center mt-5 mb-2">Pas de résultat pour cette recherche</h5>
}
MyPage.razor.cs
private bool isSearching { get; set; } = false;
private bool validSearchHasBeenStarted { get; set; } = false;

private async Task HandleValidSubmitAsync()
{
    validSearchHasBeenStarted = true;
    isSearching = true;

    var data = await myClient.FetchDataAsync(search.Text);

    isSearching = false;
}

Components

Components/MyComponent.razor

Components/MyComponent.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

EventCallback

ChildComponent.razor
<button @onclick="OnClickCallback">Click</button>

@code {
    [Parameter]
    public EventCallback<MouseEventArgs> OnClickCallback { get; set; }
}
Parent.razor
@* The ShowMessage is passed to the ChildComponent
   When the button will be pressed in the ChildComponent, the ShowMessage will be triggered in the parent *@
<ChildComponent OnClickCallback="@ShowMessage">
    Content of the child component is supplied by the parent component.
</ChildComponent>

@code {
    private void ShowMessage(MouseEventArgs e)
    { }
}

Navigation

Cs.svg
[Inject]
private NavigationManager NavigationManager { get; set; } = default!;

NavigationManager.NavigateTo("url");

// to implement redirection, use the OnInitializedAsync method otherwise you will get an NavigationException 
protected override async Task OnInitializedAsync()
{
    NavigationManager.NavigateTo("/item", true, true);
    await base.OnInitializedAsync();
}

Dependency injection

Program.cs
builder.Services.AddTransient<IMyService, MyService>();
Pages/MyPage.razor.cs
[Inject]
private IMyService myService { get; set; }
Lifetime Description
Singleton creates a single instance of the service.

All components requiring a Singleton service receive the same instance of the service.

Transient creates a new instance of the service on each component request.
Scoped creates a new instance of the service on each HTTP request but not across SignalR connection/circuit messages.

Which means only when:

  • The user closes the browser's window, then opens a new window and navigates back to the app.
  • The user closes a tab of the app in a browser window, then opens a new tab and navigates back to the app.
  • The user selects the browser's reload/refresh button.

Localization / Langue / Culture

Program.cs
builder.Services.AddLocalization();

app.UseRequestLocalization("fr-FR");

Call a web API from ASP.NET Core Blazor

Program.cs
//builder.Services.AddServerSideBlazor();

var myWebapiUrl = builder.Configuration["MyWebapiUrl"];
if (string.IsNullOrWhiteSpace(myWebapiUrl))
{
    throw new Exception("MyWebapiUrl setting is null");
}
builder.Services.AddHttpClient<IMyClient, MyClient>(
    client => client.BaseAddress = new Uri(myWebapiUrl)
);

Entity Framework Core

Program.cs
builder.Services.AddScoped(_ => new MyDbContext(builder.Configuration.GetConnectionString("MyDbContext")));
appsettings.json
{
  "ConnectionStrings": {
    "MyDbContext": "Data Source=MyServer; Initial Catalog=MyDb; Integrated Security=True; MultipleActiveResultSets=True;"
  },
MyRepository.cs
private readonly MyDbContext context;

public MyRepository(MyDbContext context)
{
    this.context= context;
}

public Task<Data> FetchDataAsync()
    => context.Data.ToListAsync();

Using a DbContext factory

DbContext isn't thread safe and isn't designed for concurrent use.
To handle multi-threads scenarios, use a AddDbContextFactory to create a DbContext for each query.

Program.cs
builder.Services.AddDbContextFactory<MyDbContext>();
MyRepository.cs
private readonly IDbContextFactory<MyDbContext> contextFactory;

public MyRepository(IDbContextFactory<MyDbContext> contextFactory)
{
    this.contextFactory = contextFactory;
}

public async Task<Data> FetchDataAsync()
{
    // create a DbContext for this query, then dispose it once the query has been executed
    using var context = contextFactory.CreateDbContext();
}

External JS libraries

LibMan library manager

Bash.svg
# install the LibMan CLI
dotnet tool install -g Microsoft.Web.LibraryManager.Cli

# create the libman.js file
libman init
# available providers: cdnjs (default), jsdelivr (choose this one), unpkg, filesystem

libman install bootswatch@5.0.1 --files dist/darkly/bootstrap.min.css

# uninstall bootstrap
libman uninstall bootstrap@5.0.1

Bootstrap

Bash.svg
# jsdelivr provider
# install only bootstrap.min.css and bootstrap.bundle.min.js (with popper)
libman install bootstrap@latest \
  --files dist/css/bootstrap.min.css \
  --files dist/css/bootstrap.min.css.map \
  --files dist/js/bootstrap.bundle.min.js \
  --files dist/js/bootstrap.bundle.min.js.map \
  --files LICENSE
# default destination path: wwwroot/lib/bootstrap
Pages/_Host.cshtml
<head>
    <!-- REPLACE <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" /> BY -->
    <link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />

    <script src="lib/bootstrap/js/bootstrap.bundle.min.js"></script>
    <script src="_framework/blazor.server.js"></script>
</body>
Pages/Error.cshtml
<head>
    <!-- REPLACE <link href="~/css/bootstrap/bootstrap.min.css" rel="stylesheet" /> BY -->
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
Delete the folder wwwroot/css/bootstrap

Bootstrap icons

Bash.svg
# install the css and fonts only
libman install bootstrap-icons@latest \
  --files font/bootstrap-icons.min.css \
  --files font/fonts/bootstrap-icons.woff \
  --files font/fonts/bootstrap-icons.woff2
# default destination path: wwwroot/lib/bootstrap-icons
Pages/_Host.cshtml
<head>
    <link rel="stylesheet" href="lib/bootstrap-icons/font/bootstrap-icons.min.css" />
    <link href="css/site.css" rel="stylesheet" />
wwroot/css/site.css
/* remove this line */
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
Delete the folder wwwroot/css/open-iconic
Shared/NavMenu.razor
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
    <i class="bi bi-house"></i> Home
</NavLink>
Shared/NavMenu.razor.css
/* replace .oi by .bi */
.bi {
    width: 2rem;
    font-size: 1.1rem;
    vertical-align: text-top;
    top: -2px;
}

Turn on detailed errors

Program.cs
builder.Services.AddServerSideBlazor()
                .AddCircuitOptions(o =>
    {
        o.DetailedErrors = true;
        o.DetailedErrors = builder.Environment.IsDevelopment();  // only add details when debugging
    });

Create a new project

Bash.svg
mkdir MySolution
cd MySolution
dotnet new sln --name MySolution

dotnet new blazorserver -o MyBlazorProject --no-https
dotnet sln add ./MyBlazorProject/MyBlazorProject.csproj