Socle métadonnées-driven — 2. L’Explorer pattern : un éditeur piloté par attributs

Ce second carnet de la série démontre le versant édition du socle MyIA.AI.Shared : l’Explorer pattern (ancre A3 du port Aricie, #19122) — une surface d’édition pilotée par les attributs plutôt que par du code d’interface.

  1. Déclarer, pas coder. Un domaine factice (paramètres de paiement) déclare son placement (onglet, section, colonne) et son comportement (mode, mot de passe, visibilité conditionnelle, sélecteur) uniquement par attributs.
  2. Modéliser sans interface. EditorModelBuilder reflète ces déclarations en un EditorModel — onglets, sections, champs — qui ne porte aucun type d’interface.
  3. Rendre sur une surface. Le modèle est rendu ici en HTML dans ce notebook (décision de surface #19122 : premier consommateur = .NET Interactive) ; une surface console ou web consommerait le même modèle à l’identique.

Prérequis : le premier carnet de la série (Socle-MetadataDriven-Csharp.ipynb) pose la décoration → introspection ; ses notions sont supposées lues.

// Setup : même DLL Release locale que le premier carnet de la série.
#r "../../../MyIA.AI.Shared/bin/Release/net9.0/MyIA.AI.Shared.dll"

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using MyIA.AI.ComponentModel.Attributes;
using MyIA.AI.ComponentModel.PropertyEditor;
using MyIA.AI.ComponentModel.PropertyEditor.Explorer;

// Sanity check : le socle chargé embarque bien la couche Explorer (T3.1).
Console.WriteLine($"socle   : {typeof(EditorModelBuilder).Assembly.GetName().Version}");
Console.WriteLine($"explorer: {typeof(EditorModelBuilder).FullName}");
socle   : 1.0.0.0
explorer: MyIA.AI.ComponentModel.PropertyEditor.Explorer.EditorModelBuilder

Deux lignes, deux preuves. La version du socle atteste que la DLL Release locale est bien celle chargée, et le nom complet confirme que la couche Explorer (builder du modèle) est embarquée dans cette même assembly — pas un paquet séparé.

Moment 1 — Déclarer : le placement vit dans les attributs

Le pattern d’origine (AdvancedGridView / PropertyEditor, WebForms DNN) décrivait un éditeur générique : la grille demandait à la réflexion où placer chaque propriété et comment elle se comporte. Le port conserve exactement cette séparation : le domaine déclare, le socle reflète, la surface rend.

Définissons un domaine factice : les paramètres d’un module de paiement. Chaque propriété porte ses propres règles de placement — onglet, section, colonne — et de comportement.

// Moment 1 : le domaine factice. Toute l'interface est déclarée par attributs.
[MainCategory("Paiement")]
public class PaiementScenario
{
    public IList<string> MoyensAcceptes { get; set; } = new List<string> { "CB", "Virement" };

    [ExtendedCategory("General", "Identite")]
    public string Endpoint { get; set; } = "https://api.exemple.fr/v1";

    [ExtendedCategory("General", "Identite", 1)]
    [PasswordMode]
    public string CleApi { get; set; } = "sk-exemple-000";

    [ExtendedCategory("General", "Apparence")]
    [Selector("My.Selecteurs.Themes, Lib", "Titre", "Id", true, true)]
    [TextField("Libelle")]
    [ValueField("Code")]
    public string Theme { get; set; } = "sombre";

    [ExtendedCategory("Avance", "Limites")]
    [ViewOnly]
    [Width(80)]
    public int TimeoutSecondes { get; set; } = 30;

    [ExtendedCategory("Avance", "Limites")]
    [ConditionalVisible("TimeoutSecondes", false, false, 0)]
    [LineCount(3)]
    public string? NotesDebug { get; set; }

    [CollectionEditor]
    [MultiSelectorType(MultiSelectionType.ListBox)]
    public IList<string>? Surveillances { get; set; }
}

Rien n’a été rendu — et c’est le point. La classe ne référence aucun type d’interface : pas de contrôle, pas de rendu, pas d’événement. Tout ce que la future surface saura d’Endpoint (première colonne de la section Identite) ou de CleApi (deuxième colonne, saisie masquée) est écrit sur la propriété elle-même.

// Le builder reflète les déclarations en un modèle arborescent.
var modele = EditorModelBuilder.Build<PaiementScenario>();

foreach (var onglet in modele.Tabs)
{
    Console.WriteLine($"[onglet] {onglet.Name}");
    foreach (var section in onglet.Sections)
    {
        Console.WriteLine($"  [section] \"{section.Name}\" (colonne {section.Column})");
        foreach (var champ in section.Fields)
        {
            var mode = champ.Mode?.ToString() ?? "defaut";
            Console.WriteLine($"    {champ.Order,2}. {champ.PropertyName,-16} {champ.PropertyType.Name,-12} mode={mode}");
        }
    }
}
[onglet] Paiement
  [section] "" (colonne 0)
     0. MoyensAcceptes   IList`1      mode=defaut
     6. Surveillances    IList`1      mode=defaut
[onglet] General
  [section] "Identite" (colonne 0)
     1. Endpoint         String       mode=defaut
  [section] "Identite" (colonne 1)
     2. CleApi           String       mode=defaut
  [section] "Apparence" (colonne 0)
     3. Theme            String       mode=defaut
[onglet] Avance
  [section] "Limites" (colonne 0)
     4. TimeoutSecondes  Int32        mode=View
     5. NotesDebug       String       mode=defaut

Trois règles de placement se lisent dans l’arbre. Les propriétés sans [ExtendedCategory] (MoyensAcceptes, Surveillances) atterrissent dans l’onglet du [MainCategory] de la classe — le renvoi que la source VB elle-même inscrit sur DefaultCategoryAttribute (devenu obsolète). Identite apparaît en deux sections : les colonnes distinctes (0 et 1) créent des sections séparées, côte à côte. Et l’ordre des champs suit l’ordre de déclaration — la clé de tri déterministe du modèle.

Moment 2 — Rendre : le modèle nourrit n’importe quelle surface

Décision de surface (#19122) : le cœur est agnostique, le premier consommateur est ce notebook. On enregistre un formateur HTML pour un fragment, puis on écrit un renderer qui ne fait aucune réflexion : il ne lit que des EditorField — placement, mode, masque, sélecteur, hauteur de zone de texte, collection.

// Moment 2 : rendu HTML. Le renderer ne reflechit pas : il lit EditorField.
using Microsoft.DotNet.Interactive.Formatting;

public record HtmlFragment(string Value);
Formatter.Register<HtmlFragment>((frag, writer) => writer.Write(frag.Value), HtmlFormatter.MimeType);

public static string RendreEditeur(EditorModel modele)
{
    var sb = new StringBuilder();
    sb.Append("<div style=\"font-family: sans-serif; line-height: 1.5\">");
    foreach (var onglet in modele.Tabs)
    {
        var open = onglet == modele.Tabs[0] ? " open" : "";
        sb.Append($"<details{open}><summary style=\"cursor:pointer\"><b>{onglet.Name}</b></summary>");
        foreach (var colonne in onglet.Sections.GroupBy(s => s.Column))
        {
            sb.Append("<div style=\"display:flex; gap:16px; flex-wrap:wrap\">");
            foreach (var section in colonne)
            {
                var editables = section.Fields.Count(f => f.Mode != PropertyEditorMode.View);
                sb.Append("<div style=\"border:1px solid #ccc; border-radius:6px; padding:6px 10px; min-width:230px\">");
                var titre = section.Name == "" ? "(racine)" : section.Name;
                sb.Append($"<i>{titre}</i> — {section.Fields.Count} champ(s), {editables} editable(s)");
                foreach (var champ in section.Fields)
                {
                    string controle = champ.IsPassword ? "<input type=\"password\" value=\"........\"/>"
                        : champ.Selector is not null ? $"<select><option>{champ.PropertyName}</option></select>"
                        : champ.Lines is > 1 ? $"<textarea rows=\"{champ.Lines}\"></textarea>"
                        : champ.IsCollection ? "<input type=\"text\" value=\"[...]\" readonly/>"
                        : "<input type=\"text\"/>";
                    var suffixe = champ.Mode == PropertyEditorMode.View ? " (lecture)" : "";
                    sb.Append($"<div><label style=\"display:block; margin:2px 0\">{champ.PropertyName}{suffixe}</label>{controle}</div>");
                }
                sb.Append("</div>");
            }
            sb.Append("</div>");
        }
        sb.Append("</details>");
    }
    sb.Append("</div>");
    return sb.ToString();
}

var html = RendreEditeur(modele);
Console.WriteLine($"Fragment HTML genere : {html.Length} caracteres, {modele.Tabs.Count} onglets.");
new HtmlFragment(html)
Fragment HTML genere : 1941 caracteres, 3 onglets.
Paiement
(racine) — 2 champ(s), 2 editable(s)
General
Identite — 1 champ(s), 1 editable(s)
Apparence — 1 champ(s), 1 editable(s)
Identite — 1 champ(s), 1 editable(s)
Avance
Limites — 2 champ(s), 1 editable(s)

Le fragment rendu montre l’éditeur complet. Trois onglets pliables, les sections disposées en colonnes flex, chaque champ avec le contrôle que ses attributs dictent : CleApi en saisie masquée, Theme en liste déroulante, NotesDebug en zone multi-lignes, MoyensAcceptes en collection en lecture seule, TimeoutSecondes estampillé lecture. Changer de surface — console, web — ne changerait aucune de ces déclarations.

Moment 3 — Décider : visibilité conditionnelle et synthèse

Une surface réelle ne rend pas tout : NotesDebug n’a de sens que si le timeout est à zéro (panne). La règle [ConditionalVisible("TimeoutSecondes", false, false, 0)] porte ce lien maître-esclave, et le modèle l’expose comme un prédicat évaluable sur une instance — la surface demande, le modèle répond.

// Moment 3 : visibilite conditionnelle evaluee sur une instance reelle.
public static bool EstVisible(EditorField champ, object instance)
{
    if (champ.Visibility.Count == 0) return true;
    var type = instance.GetType();
    return champ.Visibility.All(regle =>
    {
        if (regle.MasterPropertyName is null || regle.MatchValue is null) return true;
        var maitre = type.GetProperty(regle.MasterPropertyName)?.GetValue(instance);
        return regle.MatchValue(maitre!);
    });
}

var notes = modele.Tabs.SelectMany(t => t.Sections).SelectMany(s => s.Fields)
    .Single(f => f.PropertyName == nameof(PaiementScenario.NotesDebug));

var sain = new PaiementScenario { TimeoutSecondes = 30 };
var casse = new PaiementScenario { TimeoutSecondes = 0 };
Console.WriteLine($"Timeout=30 -> NotesDebug visible : {EstVisible(notes, sain)}");
Console.WriteLine($"Timeout=0  -> NotesDebug visible : {EstVisible(notes, casse)}");

var resume = string.Join(" | ", modele.Tabs.Select(t =>
    $"{t.Name}: {t.Sections.Sum(s => s.Fields.Count)} champ(s)"));
Console.WriteLine($"Resume du modele : {resume}");
Timeout=30 -> NotesDebug visible : False
Timeout=0  -> NotesDebug visible : True
Resume du modele : Paiement: 2 champ(s) | General: 3 champ(s) | Avance: 2 champ(s)

Le même champ, deux verdicts opposés. À timeout 30 l’esclave est caché ; à 0 il apparaît — la règle (égalité à 0, sans négation) vit dans le modèle et s’évalue contre l’instance. La ligne de synthèse, elle, est ce qu’une barre d’état afficherait : le compte de champs par onglet, lu directement sur l’arbre.

Exercice 1 — Un onglet entier par déclaration

Objectif. Définir dans la cellule une classe StockageConfig (propriétés Region, Duplicata, RetentionJours) dont l’arbre place Region et Duplicata dans l’onglet “Stockage”, section “Geo”, colonnes 0 et 1, et RetentionJours dans l’onglet “Stockage”, section “Retention”. EditorModelBuilder doit retrouver exactement cet arbre — aucun appel d’API, seuls les attributs décident.

Indice. Trois [ExtendedCategory("Stockage", ...)] bien placés suffisent.

// Exercice 1 (a completer) : une classe dont l'arbre d'onglets vient des attributs.
public class StockageConfig
{
    // TODO etudiant : Region et Duplicata -> onglet "Stockage", section "Geo", colonnes 0 et 1.
    public string Region { get; set; } = "eu-west";

    // TODO etudiant : voir Region (colonne 1).
    public bool Duplicata { get; set; }

    // TODO etudiant : onglet "Stockage", section "Retention".
    public int RetentionJours { get; set; } = 30;
}

public EditorModel? ConstruireStockage()
{
    // TODO etudiant : renvoyer EditorModelBuilder.Build<StockageConfig>().
    return null;  // TODO etudiant
}

// --- zone de validation (ne pas modifier) ---
var modeleStockage = ConstruireStockage();
if (modeleStockage is null)
{
    Console.WriteLine("Exercice 1 : non complété (renvoie null).");
}
else
{
    Console.WriteLine(string.Join(" / ", modeleStockage.Tabs.Select(t =>
        $"{t.Name} [{string.Join(';', t.Sections.Select(s => $"{s.Name}:{s.Column}"))}]")));
}
Exercice 1 : non complété (renvoie null).

Exercice 2 — La même surface, en console

Objectif. Le renderer HTML du Moment 2 ne lit que EditorField. Compléter RendreConsole(EditorModel) pour produire le même arbre en texte indenté : un onglet par ligne == Nom ==, une section par ligne indentée -- Nom (colonne N) --, un champ par ligne Nom [Type]. C’est la preuve de l’agnosticisme : zéro réflexion, zéro HTML.

Indice. Un StringBuilder et des foreach imbriqués suffisent ; le modele du Moment 1 est en portée.

// Exercice 2 (a completer) : le meme modele, rendu console (preuve d'agnosticisme).
public string? RendreConsole(EditorModel modele)
{
    // TODO etudiant : renvoyer l'arbre en texte :
    //   == Nom onglet ==
    //     -- Nom section (colonne N) --
    //       Champ [Type]
    return null;  // TODO etudiant
}

// --- zone de validation (ne pas modifier) ---
var renduConsole = RendreConsole(modele);
if (renduConsole is null)
{
    Console.WriteLine("Exercice 2 : non complété (renvoie null).");
}
else
{
    Console.WriteLine(renduConsole);
}
Exercice 2 : non complété (renvoie null).

Exercice 3 — Les champs visibles d’une instance

Objectif. EstVisible (Moment 3) tranche un champ. Compléter ChampsVisibles(EditorModel, object) pour renvoyer, dans l’ordre du modèle, les noms des champs visibles pour l’instance donnée — la requête qu’une surface pose avant de rendre.

Indice. SelectMany sur onglets puis sections, et un Where sur EstVisible.

// Exercice 3 (a completer) : la liste des champs visibles pour une instance.
public IEnumerable<string>? ChampsVisibles(EditorModel modele, object instance)
{
    // TODO etudiant : parcourir tabs -> sections -> fields,
    // garder ceux ou EstVisible(champ, instance) est vrai.
    return null;  // TODO etudiant
}

// --- zone de validation (ne pas modifier) ---
var visibles = ChampsVisibles(modele, casse);
if (visibles is null)
{
    Console.WriteLine("Exercice 3 : non complété (renvoie null).");
}
else
{
    var listing = string.Join(", ", visibles);
    Console.WriteLine($"Champs visibles (Timeout=0) : {listing}");
}
Exercice 3 : non complété (renvoie null).

Conclusion

Trois moments, un même fil rouge : l’interface est une conséquence des déclarations.

Moment Ce qui change Bénéfice
Déclarer Placement et comportement vivent dans les attributs Une nouvelle propriété apparaît dans l’éditeur sans toucher au renderer
Modéliser EditorModelBuilder reflète en un modèle sans type UI Le même modèle nourrit HTML, console ou web (#19122)
Rendre La surface ne lit que EditorField Changer de surface ne change aucune déclaration métier

Le premier carnet (Socle-MetadataDriven-Csharp.ipynb) montrait la découverte ; celui-ci montre l’édition : ensemble, ils couvrent les deux versants du socle Aricie porté (EPIC #7265). La couche d’origine (AdvancedGridView/PropertyEditor, WebForms) reste le pattern de référence ; sa transcription moderne est décidée et tracée dans #19122.

Retour au sommet