« Blazor ASP.NET Core 7.0 » : différence entre les versions
De Banane Atomic
Aller à la navigationAller à la recherche
Ligne 730 : | Ligne 730 : | ||
dotnet sln add ./MyBlazorProject/MyBlazorProject.csproj | dotnet sln add ./MyBlazorProject/MyBlazorProject.csproj | ||
</kode> | </kode> | ||
== Solution Blazor / Webapi == | |||
<kode lang='bash'> | |||
# 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 | |||
</kode> | |||
<filebox fn='item/.vscode/tasks.json' collapsed> | |||
{ | |||
"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": [] | |||
} | |||
] | |||
} | |||
</filebox> | |||
<filebox fn='item/.vscode/launch.json' collapsed> | |||
{ | |||
"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" | |||
} | |||
] | |||
} | |||
</filebox> |
Version du 25 octobre 2024 à 11:21
Links
Components libraries
Useful librairies
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
You may need of the ::deep pseudo-element to modify the style of child components. |
Pages/Items.razor.css |
/* scoped css only for this page */ /* @import is not allowed in scoped css */ first-level-tag { } first-level-tag ::deep second-level-tag { } |
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
<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> |
foreach
<ul> @foreach (var item in items) { <li> @item </li> } </ul> |
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
<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
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
Due to pre-rendering in Blazor Server you can't perform any JS interop until the OnAfterRender lifecycle method. |
Method | Start / End | Show |
---|---|---|
SetParametersAsync | Start | false |
OnInitializedAsync | Start | false |
OnAfterRenderAsync | Start | false |
OnAfterRenderAsync | End | false |
OnInitializedAsync | End | true |
OnParametersSetAsync | Start | true |
OnParametersSetAsync | End | true |
OnAfterRenderAsync | Start | true |
OnAfterRenderAsync | End | true |
SetParametersAsync | End | true |
- OnInitialized OnInitializedAsync
- OnParametersSet OnParametersSetAsync
- 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) { } } |
[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(); } |
Go to the previous page
There is no native way in Blazor to go back to the previous page, but there is still the JS way.
private IJSRuntime JSRuntime { get; set; } = default!; await JSRuntime.InvokeVoidAsync("history.back") |
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:
|
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 = await contextFactory.CreateDbContextAsync(); } |
State management
[Inject] public ProtectedSessionStorage ProtectedSessionStore { get; set; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { await LoadStateAsync(); StateHasChanged(); } } private async Task LoadStateAsync() { var fetchValueResult = await ProtectedSessionStore.GetAsync<string>("key"); var value = fetchValueResult.Success ? fetchValueResult.Value : null; } await ProtectedSessionStore.SetAsync("key", value); |
External JS libraries
LibMan library manager
# 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
# 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
# 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; } |
Handle exceptions
Shared/MainLayout.razor |
<ErrorBoundary @ref="errorBoundary"> <ChildContent> @Body </ChildContent> <ErrorContent Context="e"> @{ OnError(@e); } <MudAlert Severity="Severity.Error">An exception occured: @e.Message</MudAlert> </ErrorContent> </ErrorBoundary> @code { [Inject] private ILogger<MainLayout> Logger { get; set; } = null!; private ErrorBoundary? errorBoundary; private void OnError(Exception e) { Logger.LogCritical(e, e.Message); } // reset the ErrorBoundary to a non-error state on subsequent page navigation events by calling the error boundary's Recover method. protected override void OnParametersSet() { errorBoundary?.Recover(); } } |
Turn on detailed errors
Display detailed error in the client. Never do that in Production, rely better on server log. |
Program.cs |
builder.Services.AddServerSideBlazor() .AddCircuitOptions(o => { o.DetailedErrors = true; o.DetailedErrors = builder.Environment.IsDevelopment(); // only add details when debugging }); |
Create a new project
mkdir MySolution cd MySolution dotnet new sln --name MySolution dotnet new blazorserver -o MyBlazorProject --no-https dotnet sln add ./MyBlazorProject/MyBlazorProject.csproj |
Solution Blazor / Webapi
# 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" } ] } |