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.
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.
Modéliser sans interface.EditorModelBuilder reflète ces déclarations en un EditorModel — onglets, sections, champs — qui ne porte aucun type d’interface.
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}");
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")]publicclass PaiementScenario{public IList<string> MoyensAcceptes {get;set;}=new List<string>{"CB","Virement"};[ExtendedCategory("General","Identite")]publicstring Endpoint {get;set;}="https://api.exemple.fr/v1";[ExtendedCategory("General","Identite",1)][PasswordMode]publicstring CleApi {get;set;}="sk-exemple-000";[ExtendedCategory("General","Apparence")][Selector("My.Selecteurs.Themes, Lib","Titre","Id",true,true)][TextField("Libelle")][ValueField("Code")]publicstring Theme {get;set;}="sombre";[ExtendedCategory("Avance","Limites")][ViewOnly][Width(80)]publicint TimeoutSecondes {get;set;}=30;[ExtendedCategory("Avance","Limites")][ConditionalVisible("TimeoutSecondes",false,false,0)][LineCount(3)]publicstring? 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}");}}}
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);publicstaticstringRendreEditeur(EditorModel modele){var sb =newStringBuilder(); 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.Selectoris not null? $"<select><option>{champ.PropertyName}</option></select>": champ.Linesis>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.");newHtmlFragment(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.
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.publicclass StockageConfig{// TODO etudiant : Region et Duplicata -> onglet "Stockage", section "Geo", colonnes 0 et 1.publicstring Region {get;set;}="eu-west";// TODO etudiant : voir Region (colonne 1).publicbool Duplicata {get;set;}// TODO etudiant : onglet "Stockage", section "Retention".publicint RetentionJours {get;set;}=30;}public EditorModel?ConstruireStockage(){// TODO etudiant : renvoyer EditorModelBuilder.Build<StockageConfig>().returnnull;// TODO etudiant}// --- zone de validation (ne pas modifier) ---var modeleStockage =ConstruireStockage();if(modeleStockage isnull){ 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).publicstring?RendreConsole(EditorModel modele){// TODO etudiant : renvoyer l'arbre en texte :// == Nom onglet ==// -- Nom section (colonne N) --// Champ [Type]returnnull;// TODO etudiant}// --- zone de validation (ne pas modifier) ---var renduConsole =RendreConsole(modele);if(renduConsole isnull){ 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.returnnull;// TODO etudiant}// --- zone de validation (ne pas modifier) ---var visibles =ChampsVisibles(modele, casse);if(visibles isnull){ 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.