Dans StockAsso.FrontWebApp, j’avais besoin d’afficher des notifications globales après certaines opérations : confirmer l’ajout d’un produit, signaler un avertissement métier ou informer l’utilisateur d’une erreur.

Je n’ai pas créé de IToastService. J’ai réutilisé le mécanisme déjà présent dans l’application : ReloadComponentNotificationService.

L’implémentation réelle repose sur :

  • ViewModels.Toast et ToastSeverity ;
  • ReloadComponentNotificationService ;
  • le composant global Toast.razor ;
  • son intégration dans App.razor ;
  • Bootstrap et le module toast.js.
flowchart LR
    A["Composant métier"] -->|"NotifyAsync(new Toast(...))"| B["ReloadComponentNotificationService"]
    B -->|"EventCallback"| C["Toast.razor"]
    C -->|"Rendu d'un élément .toast"| D["DOM"]
    E["Bootstrap"] --> D

Le modèle de notification

Le modèle se trouve dans ViewModels/Toast.cs :

namespace StockAsso.FrontWebApp.ViewModels;

public enum ToastSeverity
{
    Info,
    Success,
    Warning,
    Error
}

public record Toast(string Subject, string Body, ToastSeverity Severity = ToastSeverity.Info, int? DurationInMs = null);

Le record transporte le titre, le corps, la sévérité et une éventuelle durée d’affichage.

Propriété Rôle
Subject titre de la notification
Body contenu du message
Severity type et couleur de la notification
DurationInMs délai éventuel avant la fermeture automatique

Le service existant

Le projet utilise Services/ReloadComponentNotificationService.cs :

using System.Collections.Concurrent;

using Microsoft.AspNetCore.Components;

namespace StockAsso.FrontWebApp.Services;

public class ReloadComponentNotificationService
{
    private readonly ConcurrentDictionary<string,ComponentChangedSubscription> _changedSubscriptions = new();

    private sealed class ComponentChangedSubscription(ReloadComponentNotificationService Owner, EventCallback Callback, string Key) : IDisposable
    {
        public Task NotifyAsync(object? obj = null) => Callback.InvokeAsync(obj);
        public void Dispose() => Owner._changedSubscriptions.TryRemove(Key, out var bye);
    }

    public IDisposable AddSubscription(string subscriberName,EventCallback callback)
    {
        var subscription = new ComponentChangedSubscription(this, callback, subscriberName);

        _changedSubscriptions.AddOrUpdate(subscriberName, subscription, (key, oldValue) => subscription);

        return subscription;
    }

    public Task NotifyAsync(object? obj = null)
        => Task.WhenAll(_changedSubscriptions.Select(i => i.Value.NotifyAsync(obj)));
}

Ce service n’est pas spécifique aux Toasts. Il est également utilisé pour demander à d’autres composants de se recharger, notamment des composants du panier et du compte.

Son fonctionnement est simple :

  1. un composant s’abonne avec AddSubscription ;
  2. le service conserve son EventCallback dans un ConcurrentDictionary ;
  3. NotifyAsync invoque tous les callbacks enregistrés ;
  4. l’objet passé à NotifyAsync est transmis aux abonnés.
sequenceDiagram
    participant P as Composant métier
    participant S as ReloadComponentNotificationService
    participant T as Toast.razor

    T->>S: AddSubscription("Toast", callback)
    P->>S: NotifyAsync(new Toast(...))
    S->>T: Callback.InvokeAsync(obj)
    T->>T: DisplayToast(obj)
    T->>T: Nouveau rendu

Le service est enregistré en Scoped dans StartupExtensions.cs :

builder.Services.AddScoped<Services.ReloadComponentNotificationService>();

Le composant Toast.razor

Le composant situé dans Pages/Shared/Toast.razor injecte le service :

@inject Services.ReloadComponentNotificationService ReloadComponentNotificationService

Il s’abonne ensuite sous la clé "Toast" :

@code {

    ViewModels.Toast? toast = null;
    string bgClass = "bg-success";

    protected override void OnInitialized()
    {
        ReloadComponentNotificationService.AddSubscription("Toast", EventCallback.Factory.Create(this, DisplayToast));
    }

    void DisplayToast(object displayToast)
    {
        if (displayToast as ViewModels.Toast is not null)
        {
            toast = displayToast as ViewModels.Toast;
            @if (toast!.Severity == ViewModels.ToastSeverity.Error)
            {
                bgClass = "bg-danger";
            }
            else if (toast.Severity == ViewModels.ToastSeverity.Warning)
            {
                bgClass = "bg-warning";
            }
            else if (toast.Severity == ViewModels.ToastSeverity.Info)
            {
                bgClass = "bg-info";
            }
            else
            {
                bgClass = "bg-success";
            }
        }
    }
}

EventCallback.Factory.Create relie DisplayToast au composant. Quand le service invoque ce callback, Blazor exécute la méthode puis actualise le rendu.

Le test de type est important :

if (displayToast as ViewModels.Toast is not null)

Comme le service est partagé avec d’autres composants, un appel à NotifyAsync() sans objet ne doit pas afficher de Toast.

flowchart TD
    A["NotifyAsync(obj)"] --> B["Tous les abonnés"]
    B --> C["Toast.razor"]
    C --> D{"obj est un ViewModels.Toast ?"}
    D -->|Oui| E["Mise à jour du Toast"]
    D -->|Non| F["Aucune modification"]

La correspondance des couleurs Bootstrap

Le composant traduit la sévérité en classe Bootstrap :

ToastSeverity Classe Bootstrap
Error bg-danger
Warning bg-warning
Info bg-info
Success bg-success
flowchart LR
    A["Error"] --> B["bg-danger"]
    C["Warning"] --> D["bg-warning"]
    E["Info"] --> F["bg-info"]
    G["Success"] --> H["bg-success"]

Le balisage Bootstrap rendu

Le composant ne rend rien tant que toast vaut null. Lorsqu’une notification est reçue, il produit ce balisage :

@if (toast is not null)
{
    var toastId = $"toast-notifier-{Guid.NewGuid()}";
    <div class="toast fade show" id="@toastId" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="@(toast.DurationInMs > 0)" data-bs-delay="@toast.DurationInMs" data-bs-animation="true">
        <div class="toast-header @bgClass text-@bgClass">
            <strong class="me-auto toast-subject">@toast.Subject</strong>
            <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fermer"></button>
        </div>
        <div class="toast-body @bgClass text-@bgClass ">
            @toast.Body
        </div>
    </div>
}

Un identifiant est généré à chaque rendu :

var toastId = $"toast-notifier-{Guid.NewGuid()}";

La classe show rend la notification visible. Le bouton de fermeture utilise l’attribut Bootstrap :

data-bs-dismiss="toast"

La fermeture automatique dépend de DurationInMs :

data-bs-autohide="@(toast.DurationInMs > 0)"
data-bs-delay="@toast.DurationInMs"

Sans durée, DurationInMs vaut null et data-bs-autohide reçoit false. Avec une durée positive, Bootstrap reçoit true et le délai correspondant.

Le positionnement global dans App.razor

Je ne répète pas le composant dans chaque page. Il est présent une seule fois dans Pages/App.razor :

<div class="container">
    <div class="toast-container position-fixed bottom-0 start-50 translate-middle-x mb-5">
        <Toast />
    </div>
</div>

Les classes Bootstrap placent le conteneur en position fixe, en bas de l’écran et centré horizontalement.

flowchart TB
    A["App.razor"] --> B["Routes"]
    A --> C["SectionOutlet modals"]
    A --> D["toast-container"]
    D --> E["Toast.razor"]

L’intégration JavaScript

Le fichier wwwroot/js/toast.js contient :

/**
 * Toast
 * @requires https://getbootstrap.com
*/

const toast = (() => {

  let toastElList = [].slice.call(document.querySelectorAll('.toast'));

  let toastList = toastElList.map((toastEl) => new bootstrap.Toast(toastEl));

})();

export default toast;

Ce module recherche les éléments .toast présents dans le document et crée une instance bootstrap.Toast pour chacun.

Il est importé dans wwwroot/js/stockasso.js :

import toast from './toast.js';

En développement, App.razor charge Bootstrap avant le module principal :

<script src="lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="lib/simplebar/simplebar.min.js"></script>
<script src="lib/tiny-slider/tiny-slider.min.js"></script>
<script src="lib/drift-zoom/Drift.min.js"></script>
<script src="js/stockasso.js" type="module"></script>

En production et en staging, l’application charge les bundles :

<script src="@Assets["dist/vendor.min.js"]"></script>
<script src="@Assets["dist/stockasso.min.js"]"></script>
flowchart LR
    A["Bootstrap ou vendor.min.js"] --> B["stockasso.js ou stockasso.min.js"]
    B --> C["Import de toast.js"]
    C --> D["new bootstrap.Toast(toastEl)"]

Le déclenchement réel depuis SupplierProduct.razor

Une utilisation concrète existe dans Pages/Catalog/SupplierProduct.razor.

Le composant injecte le service :

@inject Services.ReloadComponentNotificationService ReloadComponentNotificationService

Afficher un avertissement

await ReloadComponentNotificationService.NotifyAsync(new ViewModels.Toast("Attention", "Tu es présent dans plusieurs associations, il faut passer par l'interface d'admin pour ajouter des produits à l'une des boutiques", ToastSeverity.Warning));

Afficher une erreur

await ReloadComponentNotificationService.NotifyAsync(new ViewModels.Toast("Erreur", "Une erreur est survenue lors de l'ajout du produit", ToastSeverity.Error));

Afficher une confirmation

await ReloadComponentNotificationService.NotifyAsync(new ViewModels.Toast("Produit ajouté", "Ce produit a bien été ajouté à ta boutique", ToastSeverity.Success));

Le chemin complet est donc le suivant :

sequenceDiagram
    autonumber
    actor U as Utilisateur
    participant P as SupplierProduct.razor
    participant S as ReloadComponentNotificationService
    participant T as Toast.razor
    participant B as Bootstrap

    U->>P: Ajoute un produit
    P->>P: Exécute le traitement
    P->>S: NotifyAsync(new Toast(...))
    S->>T: Invoque DisplayToast
    T->>T: Stocke le Toast
    T->>T: Sélectionne bgClass
    T-->>U: Rend l'élément .toast
    B-->>U: Gère sa fermeture

Synthèse

Mon système de Toast fonctionne ainsi :

  1. ViewModels.Toast transporte les données de la notification ;
  2. Toast.razor s’abonne à ReloadComponentNotificationService sous la clé "Toast" ;
  3. un composant métier construit un ViewModels.Toast ;
  4. il transmet cet objet avec NotifyAsync ;
  5. DisplayToast vérifie son type et choisit une classe Bootstrap ;
  6. Blazor actualise le composant ;
  7. le Toast apparaît dans le conteneur global défini dans App.razor ;
  8. Bootstrap fournit le style et la fermeture.

L’appel réellement utilisé dans le projet reste direct :

await ReloadComponentNotificationService.NotifyAsync(
    new ViewModels.Toast(
        "Produit ajouté",
        "Ce produit a bien été ajouté à ta boutique",
        ToastSeverity.Success));

Cette mise en place s’appuie uniquement sur le code existant de StockAsso.FrontWebApp : Toast.cs, ReloadComponentNotificationService.cs, Toast.razor, App.razor, toast.js, stockasso.js, StartupExtensions.cs et les appels présents dans SupplierProduct.razor.