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

De Banane Atomic
Aller à la navigationAller à la recherche
 
(82 versions intermédiaires par le même utilisateur non affichées)
Ligne 3 : Ligne 3 :
* [https://learn.microsoft.com/en-us/aspnet/core/blazor/?WT.mc_id=dotnet-35129-website&view=aspnetcore-7.0 Microsoft doc]
* [https://learn.microsoft.com/en-us/aspnet/core/blazor/?WT.mc_id=dotnet-35129-website&view=aspnetcore-7.0 Microsoft doc]
* [https://github.com/AdrienTorris/awesome-blazor Awesome Blazor]
* [https://github.com/AdrienTorris/awesome-blazor Awesome Blazor]
* [https://github.com/dotnet-architecture/eShopOnBlazor Project example: eShopOnBlazor]
== Components libraries ==
* [[Radzen]]
* [[MudBlazor]]
== Useful librairies ==
* [https://github.com/Blazored/LocalStorage LocalStorage]
= Description =
* Développement frontend avec C#
* Intégration des bibliothèques .NET existantes (nuget)
== [https://webassembly.org/ WebAssembly] ==
Permet d'exécuter du bytecode (code intermediaire) dans le navigateur grâce à la javascript runtime sandbox.<br>
WebAssembly est nativement présent dans les navigateurs moderne.<br>
WebAssembly possède un runtime .NET (mono.wasm), ce qui permet d'exécuter des assemblies .NET dans le navigateur.
== [https://docs.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-7.0 Hosting models] ==
=== [https://docs.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-7.0#blazor-webassembly 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
=== [https://docs.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-7.0#blazor-server 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


= Pages =
= Pages =
Ligne 40 : Ligne 72 :


== [https://learn.microsoft.com/en-us/aspnet/core/blazor/components/css-isolation?view=aspnetcore-7.0 CSS isolation] ==
== [https://learn.microsoft.com/en-us/aspnet/core/blazor/components/css-isolation?view=aspnetcore-7.0 CSS isolation] ==
{{warn |
* you may need to refresh the browser cache}}
<filebox fn='Pages/Items.razor.css'>
<filebox fn='Pages/Items.razor.css'>
/* scoped css only for this page */
/* scoped css only for this page */
/* @import is not allowed in scoped css */
/* @import is not allowed in scoped css */
first-level-tag { }
first-level-tag ::deep second-level-tag { }
</filebox>
</filebox>


Ligne 106 : Ligne 143 :


== preventDefault and stopPropagation ==
== preventDefault and stopPropagation ==
<kode lang='cshtml'>
<kode lang='razor'>
<div @onclick="OnDivClicked">
<div @onclick="OnDivClicked">
     <a href="/page1" @onclick=OnLinkClicked
     <a href="/page1" @onclick=OnLinkClicked
Ligne 114 : Ligne 151 :
     </a>
     </a>
</div>
</div>
</kode>
= foreach =
<kode lang='razor'>
<ul>
@foreach (var item in items)
{
    <li>
        @item
    </li>
}
</ul>
</kode>
</kode>


Ligne 168 : Ligne 217 :
     </InputText>
     </InputText>
</div>
</div>
@* ValueChanged event *@
<InputText Value="@item.Name"
          ValueChanged="ItemNameChanged"
          ValueExpression="() => Item.Name">
</InputText>
</kode>
<kode lang='cs'>
private void ItemNameChanged(string value)
{
    item.Name = value;
}
</kode>
{{boxx|InputTextArea}} {{boxx|InputNumber}} {{boxx|InputDate}}
== Input Select ==
{{warn | Only support binding of type string.}}
<kode lang='razor'>
<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>
</kode>
=== [https://chrissainty.com/building-custom-input-components-for-blazor-using-inputbase/ Build your custom InputSelect to handle binding with int] ===
<filebox fn='Components/DropDown.cs' collapsed>
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)}'.");
    }
}
</filebox>
<filebox fn='_Imports.razor'>
@using MyNamespace.Components
</filebox>
=== Error InputSelect`1[System.Int32] does not support the type 'System.Int32' ===
To bind to an {{boxx|int}}, use {{boxx|select}} instead of {{boxx|InputSelect}}:
<kode lang='razor' collapsed>
<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>
</kode>
== Input CheckBox ==
{{warn | {{boxx|InputCheckbox}} requires a cascading parameter of type {{boxx|EditContext}}.<br>
For example, you can use {{boxx|InputCheckbox}} inside an {{boxx|EditForm}}.}}
<kode lang='razor'>
<div class="form-group row">
    <label for="smoker"
          class=" offset-sm-3">
        <InputCheckbox id="smoker"
                      @bind-Value="Employee.Smoker">
        </InputCheckbox>
        &nbsp;Smoker
    </label>
</div>
</kode>
<kode lang='razor'>
<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();
        }
    }
}
</kode>
== [https://blog.stevensanderson.com/2019/09/13/blazor-inputfile/ InputFile] ==
<kode lang='bash'>
dotnet add package BlazorInputFile
</kode>
<filebox fn='Pages/_Host.cshtml'>
    <script src="_content/BlazorInputFile/inputfile.js"></script>
</body>
</filebox>
<filebox fn='_Imports.razor'>
@using BlazorInputFile
</filebox>
<kode lang='blazor'>
<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";
        }
    }
}
</kode>
* [https://github.com/SteveSandersonMS/BlazorInputFile InputFile] on GitHub
== Align multiple inputs ==
<kode lang='razor' collapsed>
<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>
</kode>
</kode>


Ligne 174 : Ligne 434 :
Changes made in the UI doesn't change the binded property.<br>
Changes made in the UI doesn't change the binded property.<br>
The {{boxx|onchange}} event can be used to manually update the property.
The {{boxx|onchange}} event can be used to manually update the property.
<kode lang='blazor'>
<kode lang='razor'>
<input type="text" value="@Description"
<input type="text" value="@Description"
       @onchange=@((ChangeEventArgs __e) => Description = __e.Value.ToString())
       @onchange=@((ChangeEventArgs __e) => Description = __e.Value.ToString())
Ligne 187 : Ligne 447 :


== Two way ==
== Two way ==
<kode lang='blazor'>
<kode lang='razor'>
<input type="text" @bind="Description" />    @* by default uses the 'on change' event (focus lost) *@
<input type="text" @bind="Name" />    @* by default uses the 'on change' event (focus lost) *@
<input type="text" @bind="Description"
 
                   @bind:event="oninput" /> @* trigger the binding 'on input' event *@
<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 {
@code {
     string Description { get; set; } = "Text";
     string Name { get; set; } = "Text";
 
    async Task DoActionAsync() { }
}
}
</kode>
</kode>
Ligne 212 : Ligne 476 :
               placeholder="Enter name" />
               placeholder="Enter name" />
     <ValidationMessage class="offset-sm-3 col-sm-8" For="@(() => Item.Name)" />  @* affiche le message d'erreur de validation *@
     <ValidationMessage class="offset-sm-3 col-sm-8" For="@(() => Item.Name)" />  @* affiche le message d'erreur de validation *@
</filebox>
== [https://github.com/Blazored/FluentValidation FluentValidation] ==
<kode lang=''>
dotnet add package Blazored.FluentValidation
</kode>
<filebox fn='_Imports.razor'>
@using Blazored.FluentValidation
</filebox>
<filebox fn='Pages/MyForm.razor'>
<EditForm Model="@model" OnValidSubmit="@SubmitValidForm">
    <FluentValidationValidator />
</filebox>
=== Manual validation ===
<filebox fn='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);
    }
</filebox>
<filebox fn='Clients/OperationResult.cs' collapsed>
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[]>();
    }
}
</filebox>
</filebox>


Ligne 275 : Ligne 598 :
}
}
</filebox>
</filebox>
= [https://docs.microsoft.com/en-us/aspnet/core/blazor/components?view=aspnetcore-7.0 Components] =
<filebox fn='Components/MyComponent.razor'>
</filebox>
<filebox fn='Components/MyComponent.cs'>
[Parameter]
public EventCallback<bool> MyEventCallback { get; set; }
// invoke asynchronously callback
await MyEventCallback.InvokeAsync(true);
</filebox>
<filebox fn='_Imports.razor'>
@* Make components available *@
@using BlazorServerApp.Components
</filebox>
<filebox fn='Pages/MyPage.razor'>
<MyComponent @ref="MyComponent"
            MyEventCallback="@MyComponent_OnSomethingHappened"></MyComponent>
</filebox>
<filebox fn='Pages/MyPageBase.cs'>
protected MyComponentBase MyComponent { get; set; }
public async void MyComponent_OnSomethingHappened()
{
    // refresh the DOM if elements have changed
    StateHasChanged();
}
</filebox>
== [https://learn.microsoft.com/en-us/aspnet/core/blazor/components/lifecycle?view=aspnetcore-7.0#handle-incomplete-async-actions-at-render Lifecycle methods] ==
{{info | Due to pre-rendering in Blazor Server you can't perform any JS interop until the OnAfterRender lifecycle method.}}
{| class="wikitable wtp"
! 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
|}
# {{boxx|OnInitialized}} {{boxx|OnInitializedAsync}}
# {{boxx|OnParametersSet}} {{boxx|OnParametersSetAsync}}
# {{boxx|OnAfterRender}} {{boxx|OnAfterRenderAsync}}
== [https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-7.0#eventcallback EventCallback] ==
<filebox fn='ChildComponent.razor'>
<button @onclick="OnClickCallback">Click</button>
@code {
    [Parameter]
    public EventCallback<MouseEventArgs> OnClickCallback { get; set; }
}
</filebox>
<filebox fn='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)
    { }
}
</filebox>
= Navigation =
<kode lang='cs'>
[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();
}
</kode>
* [https://stackoverflow.com/questions/58076758/navigationerror-on-navigateto NavigationException with NavigationManager called from OnInitialized]
== [https://stackoverflow.com/questions/62561926/blazor-navigation-manager-go-back 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.
<kode lang='cs'>
private IJSRuntime JSRuntime { get; set; } = default!;
await JSRuntime.InvokeVoidAsync("history.back")
</kode>


= [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] =
Ligne 301 : Ligne 735 :
* The user selects the browser's reload/refresh button.
* The user selects the browser's reload/refresh button.
|}
|}
= [https://learn.microsoft.com/en-us/aspnet/core/blazor/globalization-localization?view=aspnetcore-7.0&pivots=server Localization / Langue / Culture] =
<filebox fn='Program.cs'>
builder.Services.AddLocalization();
app.UseRequestLocalization("fr-FR");
</filebox>
= Static files =
<filebox fn='Pages/_Host.cshtml'>
<head>
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
</filebox>
<filebox fn='wwwroot/css/site.css'>
/* custom css for the entire app, not a specific page */
/* import Iconic font */
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
</filebox>
* {{boxx|wwwroot/favicon.ico}}
== [https://fontawesome.com/how-to-use/on-the-web/setup/hosting-font-awesome-yourself Font-Awesome] ==
* {{boxx|wwwroot/css/font-awesome}}
** {{boxx|css/all.min.css}}
** {{boxx|webfonts/*}}
** {{boxx|LICENSE.txt}}
<filebox fn='wwwroot/css/site.css'>
@import url('font-awesome/css/all.min.css');
</filebox>
= [https://learn.microsoft.com/en-us/aspnet/core/blazor/call-web-api?view=aspnetcore-7.0&pivots=server Call a web API from ASP.NET Core Blazor] =
<filebox fn='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)
);
</filebox>
* [[Asp.net_core_7_web_api#Client|ASP.NET 7 webapi client]]


= [https://learn.microsoft.com/en-us/aspnet/core/blazor/blazor-server-ef-core?view=aspnetcore-7.0 Entity Framework Core] =
= [https://learn.microsoft.com/en-us/aspnet/core/blazor/blazor-server-ef-core?view=aspnetcore-7.0 Entity Framework Core] =
<filebox fn='Program.cs'>
builder.Services.AddScoped(_ => new MyDbContext(builder.Configuration.GetConnectionString("MyDbContext")));
</filebox>
<filebox fn='appsettings.json'>
{
  "ConnectionStrings": {
    "MyDbContext": "Data Source=MyServer; Initial Catalog=MyDb; Integrated Security=True; MultipleActiveResultSets=True;"
  },
</filebox>
<filebox fn='MyRepository.cs'>
private readonly MyDbContext context;
public MyRepository(MyDbContext context)
{
    this.context= context;
}
public Task<Data> FetchDataAsync()
    => context.Data.ToListAsync();
</filebox>
== [https://learn.microsoft.com/en-us/ef/core/dbcontext-configuration/#using-a-dbcontext-factory-eg-for-blazor Using a DbContext factory] ==
== [https://learn.microsoft.com/en-us/ef/core/dbcontext-configuration/#using-a-dbcontext-factory-eg-for-blazor Using a DbContext factory] ==
DbContext isn't thread safe and isn't designed for concurrent use.<br>
DbContext isn't thread safe and isn't designed for concurrent use.<br>
Ligne 314 : Ligne 819 :
private readonly IDbContextFactory<MyDbContext> contextFactory;
private readonly IDbContextFactory<MyDbContext> contextFactory;


public DataSummaryRepository(IDbContextFactory<MyDbContext> contextFactory)
public MyRepository(IDbContextFactory<MyDbContext> contextFactory)
{
{
     this.contextFactory = contextFactory;
     this.contextFactory = contextFactory;
Ligne 322 : Ligne 827 :
{
{
     // create a DbContext for this query, then dispose it once the query has been executed
     // create a DbContext for this query, then dispose it once the query has been executed
     using var context = contextFactory.CreateDbContext();
     using var context = await contextFactory.CreateDbContextAsync();
}
</filebox>
 
= [https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management?view=aspnetcore-7.0&pivots=server State management] =
<kode lang='cs'>
[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);
</kode>
 
= Javascript interop =
== [https://docs.microsoft.com/en-us/aspnet/core/blazor/call-javascript-from-dotnet Call JS from .NET] ==
<filebox fn='Pages/MyPage.razor.cs'>
[Inject]
public IJSRuntime JS { get; set; }
 
// one way to import mypage.js
await JS.InvokeVoidAsync("import", "./js/mypage.js");
 
// invoke JavaScript functions without reading a returned value
await JS.InvokeVoidAsync("MyJSMethod", "arg1");
// pass an array as argument
await JS.InvokeVoidAsync("MyJSMethod", (object)myArray);
 
// invoke JavaScript functions and read a returned value
var result = await JS.InvokeAsync<string>("AddExclamationPoint", "arg1");
</filebox>
 
<filebox fn='wwwroot/js/mypage.js'>
window.MyJsMethod = function(arg1) {
    alert(arg1);
}
 
window.AddExclamationPoint = function(arg1) {
    return arg1 + ' !';
}
</filebox>
 
Another way to import mypage.js
<filebox fn='Pages/_Layout.cshtml'>
<body>
    @* at the end of body *@
    <script src="js/mypage.js"></script>
</body>
</filebox>
 
== [https://docs.microsoft.com/en-us/aspnet/core/blazor/call-dotnet-from-javascript Call .NET from JS] ==
<filebox fn='wwwroot/js/site.js'>
window.UpdateButtonContent = function (dotnetHelper, newContent) {
    dotnetHelper.invokeMethodAsync('UpdateButtonContent', newContent);
    dotnetHelper.dispose();
};
</filebox>
 
<filebox fn='Pages/MyPageBase.cs'>
var objRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("UpdateButtonContentJS", objRef, newContent);
objRef.Dispose();
 
[JSInvokable]
public void UpdateButtonContent(string newContent)
{
    ButtonContent = newContent;
}
}
</filebox>
<filebox fn='Pages/_Host.cshtml'>
<body>
    @* à la fin de body *@
    <script src="js/myscript.js"></script>
</body>
</filebox>
</filebox>


Ligne 332 : Ligne 923 :
dotnet tool install -g Microsoft.Web.LibraryManager.Cli
dotnet tool install -g Microsoft.Web.LibraryManager.Cli


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


libman install bootswatch@5.0.1 --files dist/darkly/bootstrap.min.css
libman install bootswatch@5.0.1 --files dist/darkly/bootstrap.min.css
Ligne 345 : Ligne 936 :
== Bootstrap ==
== Bootstrap ==
<kode lang='bash'>
<kode lang='bash'>
# jsdelivr provider
# install only bootstrap.min.css and bootstrap.bundle.min.js (with popper)
# install only bootstrap.min.css and bootstrap.bundle.min.js (with popper)
libman install bootstrap@latest \
libman install bootstrap@latest \
--files dist/css/bootstrap.min.css \
  --files dist/css/bootstrap.min.css \
--files dist/css/bootstrap.min.css.map \
  --files dist/css/bootstrap.min.css.map \
--files dist/js/bootstrap.bundle.min.js \
  --files dist/js/bootstrap.bundle.min.js \
--files dist/js/bootstrap.bundle.min.js.map
  --files dist/js/bootstrap.bundle.min.js.map \
  --files LICENSE
# default destination path: wwwroot/lib/bootstrap
# default destination path: wwwroot/lib/bootstrap
</kode>
</kode>
Ligne 363 : Ligne 956 :
     <script src="_framework/blazor.server.js"></script>
     <script src="_framework/blazor.server.js"></script>
</body>
</body>
</filebox>
<filebox fn='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" />
</filebox>
</filebox>


{{info | Delete the folder {{boxx|wwwroot/css/bootstrap}}}}
{{info | Delete the folder {{boxx|wwwroot/css/bootstrap}}}}


== Bootstrap icons ==
== [https://icons.getbootstrap.com/ Bootstrap icons] ==
<kode lang='bash'>
<kode lang='bash'>
# install the css and fonts only
# install the css and fonts only
libman install bootstrap-icons@latest \
libman install bootstrap-icons@latest \
--files font/bootstrap-icons.min.css \
  --files font/bootstrap-icons.min.css \
--files font/fonts/bootstrap-icons.woff \
  --files font/fonts/bootstrap-icons.woff \
--files font/fonts/bootstrap-icons.woff2
  --files font/fonts/bootstrap-icons.woff2
# default destination path: wwwroot/lib/bootstrap-icons
# default destination path: wwwroot/lib/bootstrap-icons
</kode>
</kode>
Ligne 403 : Ligne 1 002 :
     vertical-align: text-top;
     vertical-align: text-top;
     top: -2px;
     top: -2px;
}
</filebox>
== [https://www.lightgalleryjs.com LightGallery] ==
<kode lang='bash'>
# download lightgallery
libman install lightgallery@2.3.0 \
--files lightgallery.min.js \
--files plugins/thumbnail/lg-thumbnail.min.js \
--files css/lightgallery.min.css \
--files css/lg-thumbnail.min.css \
--files fonts/lg.woff2 \
--files images/loading.gif
</kode>
<filebox fn='Pages/_Layout.cshtml'>
<head>
    <link rel="stylesheet" href="lib/lightgallery/css/lightgallery-bundle.min.css" />
    @* or *@
    <link rel="stylesheet" href="lib/lightgallery/css/lightgallery.min.css" />
    <link rel="stylesheet" href="lib/lightgallery/css/lg-thumbnail.min.css" />
</head>
<body>
    <script src="lib/lightgallery/lightgallery.min.js"></script>
    <script src="lib/lightgallery/plugins/thumbnail/lg-thumbnail.min.js"></script>
    <script src="js/site.js"></script>
</body>
</filebox>
<filebox fn='Pages/LightGallery.razor'>
<button id="open-gallery">Open gallery</button>
<button @onclick=OpenGallery>Open gallery</button>
</filebox>
<filebox fn='Pages/LightGallery.razor.cs'>
[Inject]
private IJSRuntime JS { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await JS.InvokeVoidAsync("import", "./lib/lightgallery/lightgallery.min.js");
        await JS.InvokeVoidAsync("import", "./lib/lightgallery/plugins/thumbnail/lg-thumbnail.min.js");
        await JS.InvokeVoidAsync("import", "./js/lightgallery.js");
    }
}
private async Task OnProfileNameClicked(string profileName)
{
    await JS.InvokeVoidAsync("OpenGallery", (object)pictureUrls);
}
</filebox>
<filebox fn='wwwroot/js/lightgallery.js'>
const $button = document.getElementById('open-gallery');
const dynamicGallery = lightGallery($button, {
    dynamic: true,
    dynamicEl: [
        {
            src: 'img/1.jpg',
            thumb: 'img/thumb1.jpg',
            subHtml: '<h4>Image 1 title</h4><p>Image 1 descriptions.</p>',
        },
        {
            src: 'img/2.jpg',
            thumb: 'img/thumb2.jpg',
            subHtml: '<h4>Image 2 title</h4><p>Image 2 descriptions.</p>',
        }
    ],
    mode: 'lg-fade',
    plugins: [lgThumbnail],
});
$button.addEventListener('click', function () {
    dynamicGallery.openGallery();
});
</filebox>
<filebox fn='wwwroot/js/site.js'>
window.OpenGallery = function (picturePaths) {
    let elements = picturePaths.map(x => ({
        src: x,
        thumb: x
    }));
    let gallery = lightGallery(document.getElementsByTagName('main')[0], {
        dynamic: true,
        dynamicEl: elements,
        mode: 'lg-fade',
        plugins: [lgThumbnail],
    });
    gallery.openGallery();
}
</filebox>
= [https://docs.microsoft.com/en-us/aspnet/core/blazor/security/?view=aspnetcore-7.0 Authentication and authorization] =
* [https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-7.0&tabs=netcore-cli Introduction to Identity on ASP.NET Core]
* [https://docs.microsoft.com/en-us/aspnet/core/security/authentication/customize-identity-model?view=aspnetcore-7.0 Identity model customization in ASP.NET Core]
<filebox fn='App.razor'>
@* replace RouteView by AuthorizeRouteView *@
<AuthorizeRouteView RouteData="@routeData"
                    DefaultLayout="@typeof(MainLayout)" />
</filebox>
<filebox fn='Shared/LoginDisplay.razor'>
<AuthorizeView>
    <Authorized>
        <p>@context.User.Identity.Name</p>
    </Authorized>
    <NotAuthorized>
        <a href="login">Log in</a>
    </NotAuthorized>
</AuthorizeView>
</filebox>
<filebox fn='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();
}
</filebox>
== [https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-7.0&tabs=netcore-cli#scaffold-identity-into-a-blazor-server-project-with-authorization Scaffold Identity] ==
<kode lang='bash'>
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
</kode>
* [https://medium.com/@nohorse/adding-a-custom-login-page-to-blazor-server-app-3d725a463927 Adding a custom login page to Blazor Server]
== [https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/ Authenticate with web api] ==
== [https://blazorhelpwebsite.com/ViewBlogPost/36 Cookies x] ==
* [https://docs.microsoft.com/en-us/aspnet/core/security/authentication/cookie?view=aspnetcore-3.1 Use cookie authentication without ASP.NET Core Identity]
= Infinite scrolling =
* [https://www.meziantou.net/infinite-scrolling-in-blazor.htm Infinite scrolling in Blazor]
* [https://github.com/SveNord/Sve-Blazor-InfiniteScroll  Sve-Blazor-InfiniteScroll]
== [https://github.com/ljbc1994/BlazorIntersectionObserver BlazorIntersectionObserver] ==
<kode lang='bash'>
dotnet add package BlazorIntersectionObserver
</kode>
<filebox fn='Program.cs'>
builder.Services.AddIntersectionObserver();
</filebox>
<filebox fn='ItemGallery.razor'>
<div class="d-flex flex-wrap">
    @foreach (var item in Items)
    {
        <div class="card mx-5 my-2">
            <img src=@item.Cover class="card-img-top">
        </div>
    }
</div>
<IntersectionObserve OnChange="@OnIntersectingChanged">
    <div @ref="context.Ref.Current" class="@(allItemsLoaded || context.IsIntersecting ? "d-none": "")">
        <div class="spinner-border d-block mx-auto" role="status">
            <span class="sr-only">Loading...</span>
        </div>
    </div>
</IntersectionObserve>
</filebox>
<filebox fn='ItemGallery.razor.cs'>
private bool allProfilesLoaded;
private async Task OnIntersectingChanged(IntersectionObserverEntry entry)
{
    if (entry.IsIntersecting)
    {
        CurrentItemQuery.PageIndex++;
        var fetchedItems = await this.ProfileClient.GetAsync(CurrentItemQuery);
        if (fetchedItems.Count == 0)
            allProfilesLoaded = true;
        else
            Items.AddEach(fetchedItems);
    }
}
</filebox>
= [https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/handle-errors?view=aspnetcore-7.0 Handle exceptions] =
<filebox fn='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();
    }
}
}
</filebox>
</filebox>


= [https://stackoverflow.com/questions/57514541/how-to-turn-on-circuitoptions-detailederrors Turn on detailed errors] =
= [https://stackoverflow.com/questions/57514541/how-to-turn-on-circuitoptions-detailederrors Turn on detailed errors] =
{{info | Display detailed error in the client. Never do that in Production, rely better on server log.}}
<filebox fn='Program.cs'>
<filebox fn='Program.cs'>
builder.Services.AddServerSideBlazor()
builder.Services.AddServerSideBlazor()
Ligne 414 : Ligne 1 258 :
         o.DetailedErrors = builder.Environment.IsDevelopment();  // only add details when debugging
         o.DetailedErrors = builder.Environment.IsDevelopment();  // only add details when debugging
     });
     });
</filebox>
= Create a new project =
<kode lang='bash'>
mkdir MySolution
cd MySolution
dotnet new sln --name MySolution
dotnet new blazorserver -o MyBlazorProject --no-https
dotnet sln add ./MyBlazorProject/MyBlazorProject.csproj
</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>
</filebox>

Dernière version du 16 novembre 2024 à 22:16

Links

Components libraries

Useful librairies

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.

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

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 to refresh the browser cache
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

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>

foreach

Razor.svg
<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

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>

@* ValueChanged event *@
<InputText Value="@item.Name"
           ValueChanged="ItemNameChanged"
           ValueExpression="() => Item.Name">
</InputText>
Cs.svg
private void ItemNameChanged(string value)
{
    item.Name = value;
}

InputTextArea InputNumber InputDate

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>

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.

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

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

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
  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();
}

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.

Cs.svg
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:

  • 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");

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 for the entire app, not a specific page */

/* 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');

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

Cs.svg
[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);

Javascript interop

Call JS from .NET

Pages/MyPage.razor.cs
[Inject]
public IJSRuntime JS { get; set; }

// one way to import mypage.js
await JS.InvokeVoidAsync("import", "./js/mypage.js");

// invoke JavaScript functions without reading a returned value
await JS.InvokeVoidAsync("MyJSMethod", "arg1");
// pass an array as argument
await JS.InvokeVoidAsync("MyJSMethod", (object)myArray);

// invoke JavaScript functions and read a returned value
var result = await JS.InvokeAsync<string>("AddExclamationPoint", "arg1");
wwwroot/js/mypage.js
window.MyJsMethod = function(arg1) {
    alert(arg1);
}

window.AddExclamationPoint = function(arg1) {
    return arg1 + ' !';
}

Another way to import mypage.js

Pages/_Layout.cshtml
<body>
    @* at the end of body *@
    <script src="js/mypage.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>

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;
}

LightGallery

Bash.svg
# download lightgallery
libman install lightgallery@2.3.0 \
--files lightgallery.min.js \
--files plugins/thumbnail/lg-thumbnail.min.js \
--files css/lightgallery.min.css \
--files css/lg-thumbnail.min.css \
--files fonts/lg.woff2 \
--files images/loading.gif
Pages/_Layout.cshtml
<head>
    <link rel="stylesheet" href="lib/lightgallery/css/lightgallery-bundle.min.css" />
    @* or *@
    <link rel="stylesheet" href="lib/lightgallery/css/lightgallery.min.css" />
    <link rel="stylesheet" href="lib/lightgallery/css/lg-thumbnail.min.css" />
</head>
<body>
    <script src="lib/lightgallery/lightgallery.min.js"></script>
    <script src="lib/lightgallery/plugins/thumbnail/lg-thumbnail.min.js"></script>
    <script src="js/site.js"></script>
</body>
Pages/LightGallery.razor
<button id="open-gallery">Open gallery</button>
<button @onclick=OpenGallery>Open gallery</button>
Pages/LightGallery.razor.cs
[Inject]
private IJSRuntime JS { get; set; }

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await JS.InvokeVoidAsync("import", "./lib/lightgallery/lightgallery.min.js");
        await JS.InvokeVoidAsync("import", "./lib/lightgallery/plugins/thumbnail/lg-thumbnail.min.js");

        await JS.InvokeVoidAsync("import", "./js/lightgallery.js");
    }
}

private async Task OnProfileNameClicked(string profileName)
{
    await JS.InvokeVoidAsync("OpenGallery", (object)pictureUrls);
}
wwwroot/js/lightgallery.js
const $button = document.getElementById('open-gallery');

const dynamicGallery = lightGallery($button, {
    dynamic: true,
    dynamicEl: [
        {
            src: 'img/1.jpg',
            thumb: 'img/thumb1.jpg',
            subHtml: '<h4>Image 1 title</h4><p>Image 1 descriptions.</p>',
        },
        {
            src: 'img/2.jpg',
            thumb: 'img/thumb2.jpg',
            subHtml: '<h4>Image 2 title</h4><p>Image 2 descriptions.</p>',
        }
    ],
    mode: 'lg-fade',
    plugins: [lgThumbnail],
});

$button.addEventListener('click', function () {
    dynamicGallery.openGallery();
});
wwwroot/js/site.js
window.OpenGallery = function (picturePaths) {
    let elements = picturePaths.map(x => ({
        src: x,
        thumb: x
    }));

    let gallery = lightGallery(document.getElementsByTagName('main')[0], {
        dynamic: true,
        dynamicEl: elements,
        mode: 'lg-fade',
        plugins: [lgThumbnail],
    });

    gallery.openGallery();
}

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


Infinite scrolling

BlazorIntersectionObserver

Bash.svg
dotnet add package BlazorIntersectionObserver
Program.cs
builder.Services.AddIntersectionObserver();
ItemGallery.razor
<div class="d-flex flex-wrap">
    @foreach (var item in Items)
    {
        <div class="card mx-5 my-2">
            <img src=@item.Cover class="card-img-top">
        </div>
    }
</div>
<IntersectionObserve OnChange="@OnIntersectingChanged">
    <div @ref="context.Ref.Current" class="@(allItemsLoaded || context.IsIntersecting ? "d-none": "")">
        <div class="spinner-border d-block mx-auto" role="status">
            <span class="sr-only">Loading...</span>
        </div>
    </div>
</IntersectionObserve>
ItemGallery.razor.cs
private bool allProfilesLoaded;

private async Task OnIntersectingChanged(IntersectionObserverEntry entry)
{
    if (entry.IsIntersecting)
    {
        CurrentItemQuery.PageIndex++;
        var fetchedItems = await this.ProfileClient.GetAsync(CurrentItemQuery);
        if (fetchedItems.Count == 0)
            allProfilesLoaded = true;
        else
            Items.AddEach(fetchedItems);
    }
}

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

Bash.svg
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

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"
        }
    ]
}