I. Illustration▲
L’application que je présente dans ce chapitre n’est là que comme base de départ de la réflexion. J’utilise un fichier des clients disponible dans le répertoire exemple de Delphi (<répertoire d’installation>\Samples\Data\clients.cds). En voici le design :
J’ai délibérément choisi un programme multiplateforme et donc utilise à fond les possibilités de LiveBindings pour rédiger un programme avec le moins de lignes de code possible (pour cette illustration, pas une seule).
À l’exécution, j’obtiens ceci :
La grille n’est là que pour montrer les données chargées. En réalité, seule la boîte de choix sera utilisée.
L’objectif, à savoir sélectionner le numéro du client, est atteint, mais à moins que l’utilisateur ne connaisse le petit nom de chacun en fonction de son numéro, cette boîte de choix n’est pas très adaptée. Est-ce améliorable ? Heureusement oui, en utilisant la propriété FillDisplayCustomFormat de la liaison et en la modifiant ainsi :
%s+' '
+Dataset.LAST_NAME.Text+' '
+Dataset.FIRST_NAME.Text
Pour ceux qui ne sont pas à l’aise avec l’utilisation des LiveBindings, quelques explications. Cette fonctionnalité, apparue en même temps que la possibilité de la compilation multiplateforme, contient, entre autres choses, un interpréteur d’expressions. La propriété FillDisplayCustomFormat n’est qu’une possibilité de passer une expression au cours du remplissage de la liste.
Bien sûr, par codification (par exemple en ajoutant une colonne calculée à l’ensemble de données et en codant l’événement OnCalcFields), en intervenant plus en amont dans la source de données (par l’obtention des données par SQL) ou encore en remplissant la liste par code, on peut très bien obtenir le même résultat.
Cette manipulation rend certes la boîte de choix plus explicite, mais force à élargir le composant afin de voir le texte entier. À ce propos, je signale que jouer sur la propriété ItemWidth permet d’obtenir une liste plus large que la boîte de choix.
Bien sûr, la « formule » de l’expression peut être changée. Notez surtout le Dataset.<nom_de_colonne>.text qui permet d’accéder aux valeurs.
II. Auto-complétion ▲
La première chose pratique à proposer à l’utilisateur, c’est bien la fonctionnalité d’auto-complétion, surtout si la liste est importante. Le principe en est relativement simple : si l’utilisateur frappe des touches en un laps de temps donné, la boîte se positionne sur l’élément correspondant au plus près au groupe de touches frappées et évite ainsi à l’utilisateur de faire une recherche dans toute la liste en utilisant le défilement.
II-A. Première solution : dériver le composant ▲
La dérivation du composant est simple dans le principe et implique peu de code.
Si vous avez déjà déposé un TComboBox sur votre forme, vous n’aurez même pas besoin d’indiquer l’utilisation de l’unité FMX.ListBox dans la liste des unités nécessaires (uses).
Deux variables privées sont à ajouter au TCombobox de base, une pour tester les délais (chronometre) et une autre pour mémoriser les touches (keys). En dernier lieu, la procédure OnKeyDown sera surclassée pour implémenter la fonctionnalité souhaitée.
type
TCombobox = class
(FMX.ListBox.TComboBox)
private
Chronometre:TDatetime;
Keys:string
;
protected
procedure
KeyDown(var
Key: Word
; var
KeyChar: System.WideChar; Shift: TShiftState);override
;
end
;
Vous noterez la formulation de la dérivation TCombobox = class
(FMX.ListBox.TComboBox)
Je ne crée pas un nouveau composant, mais force un nouveau comportement du TComboBox standard à l’intérieur de la forme.
Cette opération (hacking) me permet d’accéder à toutes les propriétés non publiées, comme Count ou ItemIndex, ou normalement inaccessibles comme Items.
{ TCombobox }
procedure
TCombobox.KeyDown(var
Key: Word
; var
KeyChar: System.WideChar;
Shift: TShiftState);
var
I: Integer
;
begin
if
key=vkReturn then
exit;
if
CharInSet(keychar,[chr(48
)..chr(57
),chr(65
)..chr(90
),chr(97
)..chr(122
)])
then
begin
// préférences personnelles de délai 500 ms
if
MilliSecondsBetween(Chronometre,Now)<500
then
keys:=keys+keychar
else
keys:=keychar; // sinon nouvelle séquence
//reset du chronomètre
Chronometre:=Now;
//Recherche de l'élément
for
I := 0
to
Self
.count-1
do
// if StartsText(Keys,Items[i]) then begin
if
ContainsText(Items[i],Keys) then
begin
Self
.itemindex:=i;
break; //premier élément trouvé
end
;
end
;
inherited
;
end
;
L’utilisation des fonctions MilliSecondsBetween, StartsTextWith ou ContainsText implique l’ajout des unités System.DateUtils pour la première et System.StrUtils pour les autres.
L’avantage de cette démarche est sa simplicité, mais elle n’est pas sans inconvénient :
- du fait de la dérivation du composant de base, tous les composants TCombobox sur la forme auront le même comportement, ce qui n’est peut-être pas souhaitable ;
- la modification se fait pour l’unité alors qu’une application n’en contient rarement qu’une seule ;
- le délai de frappe n’est pas modifiable ;
- les touches sont filtrées par la fonction CharInSet (chiffres, lettres majuscules et minuscules), ce qui vous a peut-être un peu choqué en ne faisant pas la part belle à l’internationalisation du procédé. Une solution alternative serait de faire l’inverse et de filtrer les touches de clavier « spéciales », mais cela suppose une bonne connaissance des divers claviers.
II-B. Deuxième solution : créer un nouveau composant▲
Dès que l’on parle de créer un nouveau composant, le doute (je n’ose écrire la peur) s’installe. Un débutant se dira que c’est compliqué. Un programmeur confirmé y verra d’autres inconvénients comme la « perte de temps », « un casse-tête n’en valant pas la peine », etc.
Si je ne peux rien répondre au programmeur confirmé, je peux au moins rassurer le débutant : non, la création d’un composant n’est pas si compliquée qu’elle en a l’air et la première section a déjà bien débroussaillé le chemin. En gros, il s’agit de reprendre dans une unité le code indiqué au-dessus, de saupoudrer d’un peu de propriétés et d’ajouter l’enregistrement du composant dans l’EDI !
Comment faire sans stress ? Sans aucune hésitation, en passant par l’assistant de création de composant (dans le menu : l’option « Composant » puis « Nouveau composant… »).
N’hésitez pas à consulter la documentation à ce propos.
En trois étapes, j’obtiens une première unité squelette.
Avant d’aller plus avant, j’aimerais expliquer pourquoi j’ai préféré passer par la création d’une simple source plutôt que de demander la création d’un nouveau package. Comme toute création de composant est à faire précautionneusement, je préfère déboguer mon code et le meilleur moyen pour cela est bien de créer le composant à l’exécution avant de l’installer. Je reporte donc à plus tard la création du paquet !
unit
AutoComplete;
interface
uses
System.SysUtils, System.Classes, FMX.Types, FMX.Controls, FMX.ListBox;
type
TAutoCompleteComboBox = class
(TComboBox)
private
{ Déclarations privées }
protected
{ Déclarations protégées }
public
{ Déclarations publiques }
published
{ Déclarations publiées }
end
;
procedure
Register
;
implementation
procedure
Register
;
begin
RegisterComponents('Tutoriels'
, [TAutoCompleteComboBox]);
end
;
end
.
Après réflexion, je vais d’ores et déjà ajouter quelques propriétés. La réponse rapide que j’ai déjà apportée mettait en lumière les variables privées Chronomometre et Keys. Le délai de frappe modifiable serait évidemment un plus. Enfin, j’envisage également l’ajout du mode de recherche (début de chaîne ou contenu dans) sensible à la casse ou non.
Après ce recensement des fonctionnalités désirées, j’insère les lignes de code suivantes dans la partie privée :
FChronometre: TDateTime;
FKeys: string
;
En préalable à la déclaration des nouvelles propriétés que je vais ajouter, je déclare un nouveau type pour définir les modes de recherche :
TSearchMode = (smStarts,smContains);
Enfin, dans la partie publiée, j’ajoute les lignes suivantes :
property
Delai: Integer
read
GetDelai write
SetDelai default
500
;
property
ModeRecherche : TSearchMode read
FSearchMode write
SetMode default
TSearchMode.smStarts;
property
CasseSensible : Boolean
read
FCaseSensitive write
SetCaseSensitive default
false
;
L’utilisation de la combinaison de touche Maj+Ctrl+C va me faciliter la tâche, en écrivant, pour moi, les lignes de code manquantes dans la classe (ajout des noms de propriétés, ajout des fonctions et procédures) et dans la partie implementation.
unit
AutoComplete;
interface
uses
System.SysUtils, System.Classes, // System.UITypes,
FMX.Types, FMX.Controls, FMX.ListBox;
type
TSearchMode = (smStarts,smContains);
TAutoCompleteComboBox = class
(TComboBox)
private
FChronometre: TDateTime;
FKeys: string
;
FDelai: Integer
;
FSearchMode : TSearchMode;
FCaseSensitive: Boolean
;
procedure
SetMode(const
Value: TSearchMode);
procedure
SetCaseSensitive(const
Value: Boolean
);
protected
procedure
SetDelai(AValue: Integer
);
function
GetDelai: Integer
;
public
published
property
Delai: Integer
read
GetDelai write
SetDelai default
500
;
property
ModeRecherche : TSearchMode read
FSearchMode write
SetMode default
TSearchMode.smStarts;
property
CasseSensible : Boolean
read
FCaseSensitive write
SetCaseSensitive default
false
;
end
;
procedure
Register
;
implementation
procedure
TAutoCompleteComboBox.SetCaseSensitive(const
Value: Boolean
);
begin
FCaseSensitive := Value;
end
;
procedure
TAutoCompleteComboBox.SetDelai(AValue: Integer
);
begin
FDelai := AValue
end
;
procedure
TAutoCompleteComboBox.SetMode(const
Value: TSearchMode);
begin
FSearchMode:=Value;
end
;
function
TAutoCompleteComboBox.GetDelai: Integer
;
begin
SetDelai(FDelai); // vérifier délai raisonnable 0 et 4 s
Result := FDelai;
end
;
procedure
Register
;
begin
RegisterComponents('Tutoriels'
, [TAutoCompleteComboBox]);
end
;
end
.
Je vais maintenant ajouter le moteur de l’auto-complétion, tel que déjà entre-aperçu dans la partie II.A. Une seule différence : prendre en compte mes différents modes de recherche.
Dans la partie protégée, j’ajoute la déclaration de la procédure KeyDown qui écrasera celle héritée :
procedure
KeyDown(var
Key: Word
; var
KeyChar: System.WideChar; Shift: TShiftState); override
;
Toutefois, pour alléger le code, je vais séparer la partie recherche dans une chaîne de la procédure et mettre celle-ci dans une fonction qui renverra l’index de l’élément de liste trouvé :
function
Recherche(AText: string
; AShowDropDown: Boolean
= True
): Integer
;
Pourquoi cette déclaration se fait-elle dans la partie publique ? Parce que, même si je n’en vois pas encore l’utilité, il me sera possible d’utiliser cette fonction à l’exécution !
J’utilise de nouveau la combinaison de touches Maj+Ctrl+C qui ajoute les lignes suivantes dans la partie implementation.
function
TAutoCompleteComboBox.Recherche(AText: string
;
AShowDropDown: Boolean
= True
): Integer
;
begin
end
;
procedure
TAutoCompleteComboBox.KeyDown(var
Key: Word
; var
KeyChar:System.WideChar; Shift: TShiftState);
begin
inherited
;
end
;
À partir de là, c’est plus une question de codage qu’autre chose ! Voici donc ma vision du processus.
procedure
TAutoCompleteComboBox.KeyDown(var
Key: Word
; var
KeyChar:System.WideChar; Shift: TShiftState);
begin
// test des caractères saisis
if
CharInSet(keychar,[chr(48
)..chr(57
),chr(65
)..chr(90
),chr(97
)..chr(122
)]) then
begin
// vérification du délai
if
MilliSecondsBetween(TimeOf(Now), FChronometre)<=FDelai
then
FKeys := FKeys + KeyChar
else
FKeys := KeyChar;
// recherche du texte et positionnement dans la liste
itemIndex:= Recherche(FKeys);
// réinitialisation du chronomètre
if
ItemIndex<>-1
then
begin
FChronometre := TimeOf(Now);
end
;
end
else
inherited
;
end
;
function
TAutoCompleteComboBox.Recherche(AText: string
;
AShowDropDown: Boolean
= True
): Integer
;
var
found : Boolean
;
begin
// au besoin, force l’affichage de la liste
if
(not
Self
.DroppedDown) and
(AShowDropDown) then
Self
.DropDown;
found :=False
;
// recherche dans la liste en fonction des options
for
result := 0
to
Self
.Items.Count - 1
do
begin
if
FSearchMode=TSearchMode.smStarts then
begin
if
FCaseSensitive
then
found:=StartsStr(FKeys, Self
.Items[Result])
else
found:=StartsText(FKeys, Self
.Items[Result]);
end
else
begin
if
FCaseSensitive
then
found:=ContainsStr(Self
.Items[Result],FKeys)
else
found:=ContainsText(Self
.Items[Result],FKeys);
end
;
if
found then
Break;
end
;
if
not
found then
Result := -1
;
end
;
Une fois l’unité réalisée et sauvegardée, je passe aux tests. Il est temps que je dévoile mon petit programme de test que vous pourrez retrouver entéléchargement ici.
Bien sûr, au design, les deux boîtes que je vais tester ne vont pas apparaître.
Quelques explications de code s’imposent, surtout en ce qui concerne le test avec un remplissage fait par l’intermédiaire des LiveBindings.
procedure
TForm11.FormCreate(Sender: TObject);
var
AAutoComplete,BAutoComplete : TAutoCompleteComboBox;
begin
// création Composant AutoComplete Test
// remplissage par code
AAutoComplete:=TAutoCompleteComboBox.Create(Self
);
with
AAutoComplete do
begin
Parent:=Self
;
Position.X:=24
;
Position.Y:=160
;
Width:=241
;
CasseSensible:=False
;
Delai:=500
;
ModeRecherche:=TSearchMode.smContains;
Clients.Active:=True
;
Clients.First;
while
not
Clients.EOF do
begin
Items.Add(format('%.0f %s %s'
,[ClientsSS_NUMBER.asFloat,
ClientsLAST_NAME.AsString,
ClientsFIRST_NAME.asString]));
Clients.Next;
end
;
Clients.Active:=False
;
end
;
// simulation LiveBindings Test
BAutoComplete:=TAutoCompleteComboBox.Create(Self
);
with
BAutoComplete do
begin
Parent:=Self
;
Position.X:=24
;
Position.Y:=224
;
Width:=241
;
CasseSensible:=False
;
Delai:=500
;
ModeRecherche:=TSearchMode.smContains;
end
;
with
TLinkFillControlToField.Create(Self
) do
// pas de synchronisation
// with TLinkListControltoField.Create(Self) do // avec synchronisation
begin
Control := BAutoComplete;
Track := True
;
FillDataSource := BindSourceDB1;
FillDisplayFieldName := 'SS_NUMBER'
;
FillDisplayCustomFormat := '%s+'#39' '#39'+Dataset.LAST_NAME.Text+'#39' '#39'+Dataset.FIRST_NAME.text'
;
AutoFill := True
;
end
;
Clients.Active:=True
;
end
;
En effet, tester la création d’un composant utilisant les LiveBindings interdit toute possibilité d’utiliser le concepteur visuel de liaison ou même l’ajout d’une liaison au TBindingsList au cours du design. J’utilise alors la technique exposée dans le tutoriel LiveBindings et POO pour créer le lien au cours de l’exécution.
Dernier point qui a son importance : vous remarquerez qu’après le remplissage par code du premier composant via une boucle classique, l’ensemble de données est refermé. Il est très important de le faire et de le rouvrir après la création par programmation du lien entre la seconde boîte de recherche et les données.
Comme les tests sont concluants, il ne me reste plus qu’à créer un nouveau paquet ou ajouter le source de mon composant dans un paquet existant avant de l’installer. Pour cela, je fais à nouveau appel à l’assistant de création de composant. Cette fois-ci, j’utilise les options du menu : Composant/Installer un composant…
Suggestion d’amélioration du composant : travailler sur le filtrage de touches de la procédure KeyDown.
Source du composant téléchargeableici.
Attention, avant de le tester, vérifiez les chemins des fichiers.
III. Alternative▲
Toujours frileux ? Je vous propose une alternative qui ouvre de nombreuses perspectives tout en restant dans l’objectif premier : l’aide au choix.
III-A. Principe▲
L’origine de ce tutoriel est, en fait, un billet de mon blog où je déplorais les limites du TComboBox. Comme d’habitude, pressé par le temps, il me fallait trouver une solution rapidement. En décomposant les fonctionnalités de la boîte de choix, j’y ai trouvé deux choses : une zone de saisie (quoiqu’en lecture seule) et une liste. D’où l’idée d’associer un TEdit et un TListView, ce dernier ayant déjà fait l’objet de plusieurs communications de ma part, aussi bien dans mon blog que via divers tutoriels que vous pourrez retrouver sur mon site personnel
Le design est donc simple et, bien sûr, la liste ne sera visible qu’à la demande.
Et le résultat est au rendez-vous avec quelques lignes de code seulement ! Le remplissage de la liste (plus de 4000 éléments) se fait via Livebindings.
// montrer la liste
procedure
TForm1.SearchEditButton1Click(Sender: TObject);
begin
ListeClients.Visible:=True
;
end
;
// cacher la liste en cas de sortie (clic dans une autre zone de la forme)
procedure
TForm1.ListeClientsExit(Sender: TObject);
begin
ListeClients.Visible:=False
;
end
;
// gérer la sélection de l'élément
procedure
TForm1.ListeClientsItemClick(const
Sender: TObject;
const
AItem: TListViewItem);
begin
/// récupération de la valeur
/// première solution, traitement du texte affiché
// SearchClient.Text:=LeftStr(AItem.Text,4);
/// deuxième solution, la valeur souhaitée est reportée, seule,
/// dans une zone de l'item de liste (cachée ou non)
// SearchClient.Text:=AItem.Detail;
/// troisième solution, rendue possible par la synchronisation
SearchClient.Text:=Datas.FDClientsNUM_CLIENT.AsString;
ListeClients.Visible:=False
;
end
;
// abandon de la recherche par l’utilisation de la touche Escape
procedure
TForm1.ListeClientsKeyDown(Sender: TObject; var
Key: Word
;
var
KeyChar: Char
; Shift: TShiftState);
begin
if
Key=vkEscape then
begin
ListeClients.Visible:=False
;
TEdit(ListeClients.Parent).SetFocus;
end
;
end
;
procedure
TForm1.ListeClientsExit(Sender: TObject);
begin
ListeClients.Visible:=False
;
end
;
Toutefois, impossible d’intercepter l’utilisation la touche Escape à l’intérieur de la boîte de recherche ! Pour pouvoir le faire, il faudra, en premier lieu, indiquer l’utilisation de l’unité FMX.SearchBox, puis associer l’événement ListeClientsKeyDown à la boîte de recherche
uses
... FMX.SearchBox;
procedure
TForm1.FormCreate(Sender: TObject);
begin
...
// association de l'évènement
if
ListeClients.controls[1
].ClassType = TSearchBox
then
TSearchBox(ListeClients.controls[1
]).OnKeyDown:=ListeClientsKeyDown;
end
;
Vous excuserez la censure, l’image fournie ici étant réalisée à partir de données d’entreprise !
L’utilisation de cette technique a un avantage certain puisque la liste peut être largement personnalisable, par exemple en y ajoutant des groupes.
Ci-dessous, une boîte de choix sur l’ensemble de données customer.cds fourni par Embarcadero dans le répertoire exemple (..\samples\data) que vous retrouverez aussi dans le second exemple.
III-B. Utilisation de cadres▲
Mettre ces deux composants ainsi qu’une partie du code dans un cadre FireMonkey pourrait également être une solution intéressante et permettrait de créer un pseudo-composant. Je vous invite à visionner à ce propos le webinaire animé par Patrick Prémartin : Créer des composants visuels sans faire de composant.
J’ai donc tenté l’expérience. Tout d’abord, voici mon cadre :
Posé sur une forme, voilà un premier résultat encourageant :
Malgré cela, j’émettrai une première critique : les TBindingsList, TBindSource et TClientDataset commencent à foisonner ! S’il y en a peu, cela peut rester gérable ; en tout cas, le concepteur visuel de liaisons reste utilisable.
Ma crainte était surtout la suivante : la liste serait-elle « contrainte » à la taille du cadre ou s’afficherait-elle en entier ? Elle s’affiche en entier, telle que dessinée, ce qui est la bonne nouvelle. Au moment du design, je peux donc réduire, après avoir fait les ajustements de la liste, le cadre à la taille de la zone de saisie.
Suite à ce premier test, l’éditeur de liaisons démontrait que modifier la source de données était possible, mais il me fallait écrire un code qui n’utilisait aucun nom de colonne et aucune synchronisation. Comme c’était moins pratique, j’ai dû faire une première concession au niveau du remplissage du TEdit associé à la liste. J’ai choisi de changer l’apparence de l’élément de liste (ListItem en ListItemRightDetail), d’indiquer au niveau des liens que la valeur à récupérer se trouve dans l’objet detail, objet que je peux même rendre invisible.
Pourquoi l’objet detail ? Parce qu’il est très facile d’y accéder !
Il est très important de changer le type d’élément de liste. Presque tous les types contiennent l’élément detail, il faut juste faire attention à ce que ce soit un de ceux-là !
Il suffit alors de changer le code lors de la sélection de l’élément de liste :
// Concession
procedure
TComboBoxFrame.ListeItemClick(const
Sender: TObject;
const
AItem: TListViewItem);
begin
Edit1.Text:=AItem.Detail; // valeur sélectionnée
Liste1.Visible:=False
;
Edit1.SetFocus ;
end
;
Résultat :
Cette solution est compliquée puisqu’elle nécessite plus de manipulations au moment du design, mais pas insurmontable. Par ailleurs, je peux désormais changer au moment du design les sources de données et les liens et obtenir deux listes différentes à partir d’un même cadre. De là ma réflexion suivante : autant retirer toute notion de données et de liaisons.
Toutes ces « améliorations » ont un coût. Ce qui, au départ, n’était qu’une utilisation d’un cadre avec ses données devient plus une copie de deux composants avec, je le concède, quelques méthodes particulières. Il y a de plus en plus d’opérations à mener après le placement du cadre :
- établir les liaisons (LiveBindings) ;
- ajuster la position de la liste ;
- rendre la liste invisible une fois le design terminé ;
- réduire le cadre à la taille de la zone de saisie ;
- etc.
Au sein d’un même projet, l’utilisation de ce dernier cadre reste donc très pratique.
La dernière étape décrite au cours du webinaire consistait à faire de ce cadre un vrai composant, mais elle s’est révélée un échec dans ce cas. Bien que la technique exposée par Patrick Prémartin soit possible, ce qui cloche c’est que tout ou presque est non modifiable, dont toute la partie concernant les LiveBindings. Je n’ai pas poussé plus avant après ce constat.
Je déduirai de cette expérience que l’alternative est applicable avec des cadres, voire des templates de cadre, mais pas avec un composant créé à partir d’un cadre.
Vous retrouverez mes essais dans ce projet.
Attention, pour le tester, vérifiez les chemins des fichiers de données et ajoutez le cadre dans votre liste de composants.
III-C. Astuces▲
N’hésitez pas à modifier les propriétés des éléments de liste.
Ou même à basculer en mode conception pour rendre votre liste conforme à vos souhaits.
IV. Pour conclure▲
Je vous ai exposé trois méthodes pour améliorer l’utilisation de boîtes de choix :
- dériver la classe principale ;
- créer un nouveau composant ;
- utiliser un TEdit associé à un TListView et exploiter toutes les possibilités de cette dernière. Et, en option, utiliser cette alternative dans un cadre.
J’espère aussi avoir un peu vulgarisé la création de composant par héritage !
IV-A. Références utilisées ▲
Je ne pourrais citer toutes les questions posées dans les forums, ce qu’un moteur de recherche avec quelques mots clés pourra facilement vous ressortir. Je tiens cependant à signaler ces deux articles de Brovin Yaroslav : « Nouvelle approche de développement de contrôles FireMonkey 'Contrôle - Modèle – Présentation' » Partie 1, Partie 2 TEdit avec auto complétion, qui pourraient ouvrir d’autres perspectives intéressantes.
IV-B. Remerciements▲
Au terme de cet article, je tiens à remercier les différents intervenants. Ceux qui, au cours d’une discussion dans le forum m’ont permis de saisir les subtilités de la dérivation utilisée chapitre II.A et mes quelques testeurs. Patrick Prémartin sans qui je n’aurais jamais songé aux cadres du chapitre III.B et, par ricochet, l’organisateur de ce webinaire Maxime Capellot.
Et, bien évidemment, un immense merci à l’équipe rédactionnelle tant aux relecteurs techniques gvasseur58 qu’au correcteur f-leb de mon orthographe et grammaire quelques fois hésitantes !