...ou comment gérer une structure composite multiple dans les cellules d'un tableau.
A la façon d'un tutoriel, je vous propose étape par étape comment réaliser un tableau sophistiqué avec GWT et Sencha GXT, les problèmes rencontrés et les solutions adoptées. Un prérequis raisonnable pour la lecture de cet article est une connaissance de GWT, même de niveau débutant ; quant à Sencha GXT ce n'est pas nécessaire : on va découvrir cette librairie ensemble.
Effet souhaité
Plutôt qu'un long discours, l'idée est d'obtenir ceci :
Un casse réussi, c'est un casse bien préparé ! |
Ce tableau permet d'ajouter plusieurs items (de la liste de gauche) dans une cellule de grille (partie à droite), avec plusieurs mécanismes d'ajouts dont le Drag N Drop.
Les étapes pour réaliser ce tableau :
- Définir les objets du modèle
- Faire une ébauche du tableau
- Gérer le Drag N Drop
- Définir les grilles
- Gérer la colonne qui contient la structure composite multiple
- Traiter les cas problématiques
Si vous voulez essayer de faire tourner le code en même temps que la
lecture de cet article, le mieux est de commencer avec le POM du projet, le module GWT, et la page d'accueil HTML sous Github.
Objets du domaine
On va rester sur un modèle très simple. Notre exemple traite d'une "association de malfaiteurs" : on a d'une part des gangsters qui ont un nom et une aptitude ("Rantanplan" qui n'est pas terrible, "Joe" qui est vraiment brillant etc), et d'autre part les holdups qui ont une cible ("Banque", "Bijouterie", etc) auquel on associe une liste de gangsters :
public class Gangster implements Serializable { private int id; private Aptitude aptitude; private String name; private static int COUNTER = 0; public Gangster(Aptitude aptitude, String name) { this(COUNTER++, aptitude, name); } public Gangster(int id, Aptitude aptitude, String name) { this.id = id; this.aptitude = aptitude; this.name = name; } // + getters and setters }
public enum Aptitude { BEST, BETTER, AVERAGE, BAD, WORST }
public class Holdup implements Serializable { private int id; private String target; private List<Gangster> gangsters = new ArrayList<Gangster>(); private static int COUNTER = 0; public Holdup() { this.id = COUNTER++; } public Holdup(String target) { this(); this.target = target; } public Holdup(String target, List<Gangster> gangsters) { this(target); this.gangsters = gangsters; } // + getters and setters }
Squelette de la solution
Je me suis inspiré d'un exemple existant :
http://www.sencha.com/examples/#ExamplePlace:gridtogrid
http://www.sencha.com/examples/#ExamplePlace:gridtogrid
Si vous consultez le code source de cet exemple, vous verrez qu'on a une bonne base de départ. J'ai donc gardé un seul des deux tableaux, adapté le modèle de sorte à prendre mes propres objets métiers (sans proxy et en les chargeant en dur).
Je me retrouve avec un tableau comportant une grille de gangsters et une grille de holdups :
public class Conspiracy implements EntryPoint, IsWidget { public void onModuleLoad() { RootPanel.get().add(this); } Label tip = new Label("Select items from the left list to add in the right table."); public Widget asWidget() { // define a frame with 2 grids FramedPanel panel = new FramedPanel(); panel.setHeadingText("Criminal conspiracy"); panel.setPixelSize(800, 400); ListStore<Gangster> gangsters = getGangsters(); Grid<Gangster> gangstersGrid = getGangstersGrid(gangsters); ListStore<Holdup> holdups = getHoldups(); Grid<Holdup> holdUpGrid = getHoldupsGrid(holdups); // TODO : DnD management // draw the layout : // ------------------------------ // | Gangsters | Holdups | // |----------------------------| // | Tips | // ------------------------------ VerticalLayoutContainer vContainer = new VerticalLayoutContainer(); HorizontalLayoutContainer hContainer = new HorizontalLayoutContainer(); hContainer.add(gangstersGrid, new HorizontalLayoutData(.3, 1, new Margins(5))); hContainer.add(holdUpGrid, new HorizontalLayoutData(.7, 1, new Margins(5, 5, 5, 0))); vContainer.add(hContainer,new VerticalLayoutData(1, 1)); vContainer.add(tip,new VerticalLayoutData(-1, -1)); panel.add(vContainer); return panel; } }
Les méthodes
getGangsters()
et getHoldups()
créent les listes de valeurs ; dans mon exemple, elles sont claquées en dur mais il est évident que dans une application réelle, celles-ci seraient chargées depuis une base de données (ci-dessus j'ai omis le code qui ajoute les items à une liste, ceci étant sans intérêt).
Ensuite
getGangstersGrid()
et getHoldupsGrid()
permettent de créer les grilles qui affichent ces données, c'est à dire de définir les colonnes de la grille. On peut se contenter pour l'instant de fournir le minimum requis comme dans l'exemple d'origine, on reviendra dessus plus tard (partie également omise dans le code ci-dessus).Implémentation du comportement spécifique du Drag N Drop
Si on manipule l'exemple de base on constate :
- dans le premier exemple que la cible peut être n'importe quelle zone l'élément qu'on fait glisser : le tableau est trié automatiquement sur ses éléments ;
- le second tableau n'est pas trié : l'élément ajouté peut être inséré avant ou après une ligne du tableau.
L'ajout se fait entre 2 lignes, il n'est pas possible de Dropper la sélection dans une cellule |
Ce qui manque simplement à la grille cible, c'est de pouvoir réagir aux mouvements de la souris, de sorte qu'on puisse identifier la cellule survolée : celle qui deviendra la cible du Drop.
Après une recherche rapide dans le Web, je suis tombé sur le code suivant. C'est exactement ce dont j'ai besoin ; j'ai alors juste créé la classe
GridHoverHandler
qui n'est qu'une pâle copie de ce code, et à partir duquel je peux capturer la cellule cible du Drop au survol de la souris. Il ne me reste plus qu'à gérer la source du Drag et la cible du Drop :Holdup holdupHover; // the target line where to drop items public Widget asWidget() { // ... // track mouse hover on the target grid new GridHoverHandler<Holdup>(holdUpGrid) { @Override public void setObjHover(Holdup object, boolean isHovered) { if (isHovered) { // drop target holdupHover = object; } else { holdupHover = null; } } }; GridDragSource<Gangster> gangstersDragSource = new GridDragSource<Gangster>(gangstersGrid) { @Override // disable MOVE operation protected void onDragDrop(DndDropEvent event) {} }; GridDropTarget<Holdup> target = new GridDropTarget<Holdup>(holdUpGrid) { @Override protected void onDragDrop(DndDropEvent event) { if (holdupHover != null) { Object o = event.getData(); List<Gangster> gangsters = holdupHover.getGangsters(); if (o instanceof List) { List<Gangster> g2 = (List<Gangster>) o; for (Gangster g: g2) { if (! gangsters.contains(g)) { gangsters.add(g); } } } holdUpGrid.getView().refresh(false); } } }; }
Les points importants sont :
- surcharger
onDragDrop()
de la source, sinon l'élément déplacé sera supprimé d'où il vient ; or on veut les conserver dans la liste de départ et ainsi pouvoir les sélectionner plusieurs fois - les données de l'événement
DndDropEvent
sont une liste de gangsters, étant donné que la grille permet la sélection multiple une fois un élément ajouté à la grille cible, il faut penser à mettre à jour la vue, ce que faitholdUpGrid.getView().refresh(false)
Là, j'apprécie particulièrement le confort donné par Sencha pour gérer le Drag N Drop : on a une classe de base déjà très complète qu'il suffit de surcharger pour implémenter ses propres comportements spécifiques. Et comme on a accès aux sources, il suffit de regarder comment c'est fait pour identifier où ajouter ses propres traitements.
Définition des grilles
Le point suivant concerne les cellules dans les grilles. Commençons par créer la grille des gangsters :
private Grid<Gangster> getGangstersGrid( ListStore<Gangster> gangsters) { return new Grid<Gangster>( gangsters, createGangsterColumnList()); } private ColumnModel<Gangster> createGangsterColumnList() { ColumnConfig<Gangster, String> nameCol = new ColumnConfig<Gangster, String>(/* TODO */); nameCol.setHeader(SafeHtmlUtils.fromString("Gangster")); ColumnConfig<Gangster, Aptitude> aptitudeCol = new ColumnConfig<Gangster, Aptitude>(/* TODO */); aptitudeCol.setHeader(SafeHtmlUtils.fromString("Apt.")); aptitudeCol.setWidth(40); // TODO : render the aptitude cell properly List<ColumnConfig<Gangster, ?>> list = new ArrayList<ColumnConfig<Gangster, ?>>(); list.add(nameCol); list.add(aptitudeCol); return new ColumnModel<Gangster>(list); }
Il y a plusieurs concepts qui entrent en jeu ici :
- La grille se construit à partir de l'objet
ColumnModel
qui définit la configuration des colonnes, dont il suffit de fournir la liste - Chaque configuration de colonne est créée avec un
ColumnConfig
basé d'une part sur le modèle de données<Gangster>
et sur le type de donnée traité par la colonne, qui est<String>
pour la première colonne et<Aptitude>
pour la deuxième. Il faut fournir au constructeur le moyen de fournir les valeurs à partir d'un gangster particulier (c'est ce qui est attendu dans le commentaire/* TODO */
). - Il faut trouver un moyen d'afficher l'aptitude avec un dégradé de couleur ; en CSS c'est trivial, en GWT ça n'est pas bien compliqué.
Fort heureusement, Sencha permet de générer un extracteur de valeurs depuis
un gangster avec
GWT.create()
. Il suffit de créer une interface (qui étend PropertyAccess
) dont les règles de nommage
indiquent comment se fait le mapping ; si les noms de champs de la structure ne conviennent pas, les annotations
fournies permettent de les redéfinir. Le code est le suivant :interface GangsterProperties extends PropertyAccess<Gangster> { @Path("id") ModelKeyProvider<Gangster> id(); @Path("name") ValueProvider<Gangster, String> name(); @Path("aptitude") ValueProvider<Gangster, Aptitude> aptitude(); // Generate the instance that maps the model to the methods static GangsterProperties INSTANCE = GWT.create(GangsterProperties.class); }
Il ne reste qu'à utiliser ces mappings dans la définition des colonnes :
nameCol = new ColumnConfig<Gangster, String>( GangsterProperties.INSTANCE.name());
aptitudeCol = new ColumnConfig<Gangster, Aptitude>( GangsterProperties.INSTANCE.aptitude());
Concernant la mise en forme de l'aptitude (comme ceci :
),
on veut obtenir un dégradé de couleur
qui s'exprime en CSS avec cela :
background: linear-gradient(to left, red, rgba(255,0,0,0))
Les deux dernières valeurs
red
et rgba(255,0,0,0)
dépendent de l'aptitude, j'ai donc simplement ajouté la méthode public abstract String gradient()
à l'énumération Aptitude
dont chaque valeur retournera un dégradé spécifique.
En GWT, l'insertion de CSS et de HTML ne se fait pas directement :
GWT nous force à travailler sur des données saines et prévient ainsi les failles de sécurité comme le XSS
et l'injection par CSS.
GWT offre des classes adaptées à la production de données HTML et CSS sécurisées. Ce qui donne pour la CSS :
private SafeStyles getBackgroundStyle(Aptitude aptitude) { return new SafeStylesBuilder() .trustedNameAndValue("background", "linear-gradient(to left, "+aptitude.gradient()+")") .toSafeStyles(); }
...et pour le HTML, GWT permet de générer le HTML sain à partir d'un template :
public interface AptitudeTemplates extends SafeHtmlTemplates { @Template("<div style=\"{0} display: inline-block; width: 20px ;\"> </div>") SafeHtml render(SafeStyles bgStyle); static AptitudeTemplates INSTANCE = GWT.create(AptitudeTemplates.class); }
On voit bien que le HTML est construit avec le CSS précédent
(c'est le paramètre
{0}
qui est passé à la méthode),
lui même obtenu de l'aptitude du gangster.
Il reste encore à l'utiliser dans la définition de la colonne de la grille,
la personnalisation de la cellule étant basée sur une
AbstractCell
:aptitudeCol.setCell(new AbstractCell<Aptitude>() { @Override public void render(Cell.Context context, Aptitude value, SafeHtmlBuilder sb) { sb.append(AptitudeTemplates.INSTANCE.render( getBackgroundStyle(value))); } });
Tout cela nous permet d'obtenir une grille de gangsters mise en forme comme souhaité.
On peut ensuite agrémenter la grille de quelques comportements supplémentaires :
- lorsqu'une cellule est sélectionnée, le "tip" en bas du tableau affiche une aide appropriée
- lorsqu'on double-clic sur un gangster, il est ajouté à tous les holdups sélectionnés dans la seconde grille, ou un avertissement indique qu'aucun holdup n'est sélectionné
(vous pouvez consulter ces détails dans le code source sous Github)
Affichage dans une cellule d'une structure composite multiple
Pour la grille des holdups, le principe est à peu près le même (je vous laisse
faire le portage du code des gangsters) ; sauf que dans la grille des holdups on
a une cellule qui doit afficher une
List<Gangster>
et sur
chacun des éléments on doit gérer le Drag N Drop et la possibilité de le
supprimer, ainsi que le Drag N Drop sur l'ensemble des éléments d'une cellule.
Comme pour les gangsters, la solution est dans l'utilisation d'une
AbstractCell
à qui est confiée la gestion de l'affichage, et
à laquelle on va ajouter la gestion des événements du drop.
Pourquoi ne pas utiliser de widget dans une cellule ? Les frameworks Javascript sont confrontés aux problèmes des
contenus riches affichés dans des tableaux de valeur, avec potentiellement un
nombre élevé de valeurs diverses à afficher dans de jolis widgets. Utiliser des widgets est pratique mais coûteux en
ressources et en performances lorsqu'on en a trop : cela force le navigateur
a maintenir en parallèle l'arborescence du DOM avec celle des widgets, avec une
surconsommation de mémoire et de gestion des événements à propager à ces widgets.
GWT est soucieux des performances, et considère que dans une grille le seul widget
qui vaut la peine d'être géré est la grille, le contenu des cellules devant être
produit avec du HTML. Le prix de la performance est qu'il faut gérer soit même les
événements au lieu de les confier au widget (puisqu'il n'y en a plus dans la cellule).
Fort heureusement, des composants standards existent déjà pour les cellules (bouton, date picker, etc),
tous basés sur
AbstractCell
, et rien n'empêche de les utiliser, de les modifier,
de les combiner, etc.
Dans ce qui suit, nous allons nous contenter d'utiliser un rendu HTML, puis après, nous
utiliserons l'un de ces composants standards. Tout d'abord le code pour l'affichage :
@Template("<button>{0} <span style=\"{2} border: 1; " + " display: inline-block; width: 20px ;\"> </span>" + " {1}$ <img src=\"cross.png\"></button>") SafeHtml render(String gangster, int price, SafeStyles bgStyle);
gangstersCol.setCell( new AbstractEventCell<List<Gangster>>() { @Override public void render(Cell.Context context, List<Gangster> value, SafeHtmlBuilder sb) { if (value == null) { return; } for (Gangster gangster: value) { int price = gangster.getName().length(); sb.append( GangstersTemplates.INSTANCE.render( gangster.getName(), price, getBackgroundStyle(gangster.getAptitude()))); } }
Pas parfait : il faudrait que lorsque la cellule est pleine de gangsters, les suivants soient visibles.
Cherchons comment faire puis corrigeons cela :
sb.append( SafeHtmlUtils.fromSafeConstant( "<div style=\"white-space: normal;\">")); for (Gangster gangster: value) { int price = gangster.getName().length(); sb.append( GangstersTemplates.INSTANCE.render( gangster.getName(), price, getBackgroundStyle(gangster.getAptitude()))); } sb.append(SafeHtmlUtils.fromSafeConstant("</div>"));
Occupons-nous maintenant des événements de la souris à gérer ;
l'idée étant de savoir si on a cliqué sur l'icône de fermeture
ou sur le bouton pour initier un Drag N Drop ; pour cela, on va ajouter
un attribut HTML sur le bouton (
data-index
) et l'image (data-close-index
) :
@Template("<button data-index=\"{3}\">{0} <span style=\"{2}" + " border: 1; display: inline-block; width: 20px ;\">" + " </span> {1}$ <img src=\"cross.png\"" + " data-close-index=\"{3}\"></button>") SafeHtml render(String gangster, int price, SafeStyles bgStyle, int index);
...et passer l'index à la méthode
render()
:int i = 0; for (...) { ... GangstersTemplates.INSTANCE.render( gangster.getName(), price, getBackgroundStyle(gangster.getAptitude()), i++));
Le reste concerne la gestion pure des événements avec l'exploitation des attributs
data-index
et data-close-index
;
je déclare une variable qui m'indique quel gangster est survolé par la souris :Gangster gangsterHover; // an item to drag in the holdup grid
...puis je remplace
AbstractCell
par AbstractEventCell
pour faciliter la prise en charge des événements.
La documentation de AbstractCell
donne un exemple de cellule supportant les événements.
gangstersCol.setCell( new AbstractEventCell<List<Gangster>>( BrowserEvents.CLICK, BrowserEvents.MOUSEDOWN, BrowserEvents.MOUSEUP) { @Override public void onBrowserEvent( Cell.Context context, com.google.gwt.dom.client.Element parent, List<Gangster> value, NativeEvent event, ValueUpdater<List<Gangster>> valueUpdater) { // Handle the click event. if (BrowserEvents.CLICK.equals(event.getType())) { // Ignore clicks that occur outside of the // outermost element. EventTarget eventTarget = event.getEventTarget(); String index = Element.as(eventTarget) .getAttribute("data-close-index"); if (index != null && index.length() > 0) { value.remove(Integer.parseInt(index)); valueUpdater.update(value); } } if (BrowserEvents.MOUSEDOWN.equals(event.getType())) { gangsterHover = null; EventTarget eventTarget = event.getEventTarget(); String index = Element.as(eventTarget) .getAttribute("data-close-index"); if (index != null && index.length() > 0) { value.remove(Integer.parseInt(index)); valueUpdater.update(value); } else { Element buttonElem = Element.as( event.getEventTarget()) for (; buttonElem != null; buttonElem = buttonElem .getParentElement()) { index = buttonElem .getAttribute("data-index"); if (index != null && index.length() > 0) { gangsterHover = value.get( Integer.parseInt(index)); } } } } else if (BrowserEvents.MOUSEUP.equals( event.getType())) { gangsterHover = null; } super.onBrowserEvent( context, parent, value, event, valueUpdater); } }
Dans le code ci-dessus, un clic sur un élément ayant l'attribut
data-close-index
me permet d'obtenir l'item à supprimer. Si j'obtiens dans la hiérarchie un élément ayant
l'attribut data-index
en appuyant la souris, c'est sûrement un début de Drag
alors je conserve le gangster concerné dans la variable gangsterHover. Je dois aussi ajouter
à la grille des holdups le support du Drag :
GridDragSource<Holdup> holdupDragSource = new GridDragSource<Holdup>(holdUpGrid) { @Override // disable MOVE operation protected void onDragDrop(DndDropEvent event) {} @Override protected void onDragStart(DndDragStartEvent event) { Element r = grid.getView().findRow( event.getDragStartEvent().getStartElement() ).cast(); if (r == null) { event.setCancelled(true); return; } int size = 0; if (gangsterHover == null) { super.onDragStart(event); // hold the line Object o = event.getData(); if (o != null) { List<Gangster> gangsters = new ArrayList<Gangster>(); List<Holdup> holdups = (List<Holdup>) o; for (Holdup h: holdups) { for (Gangster g: h.getGangsters()) { if (! gangsters.contains(g)) { gangsters.add(g); } } } size = gangsters.size(); if (size > 0) { event.setData(gangsters); } else { event.setData(null); event.setCancelled(true); } } } else { size = 1; event.setData(gangsterHover); } if (! event.isCancelled()) { if (getStatusText() == null) { event.getStatusProxy().update(getMessages() .itemsSelected(size)); } else { event.getStatusProxy().update( Format.substitute(getStatusText(), size)); } } } };
Rien ne s'invente : c'est vraiment en regardant dans le code de Sencha que j'ai trouvé
le moyen d'obtenir l'élément sur lequel on a cliqué. La variable
size
sert
à afficher pendant le Drag le nombre d'items qui ont été sélectionnés.
Il faut aussi penser dans
GridDropTarget
à traiter
non pas seulement les listes de gangsters sélectionnés, mais aussi un gangster tout seul
ce qui correspond à la partie du code où gangsterHover != null
.Ze bug with Firefox
C'est assez rare de se prendre une incompatibilité de navigateur avec GWT : dans 99,99% des usages,
GWT se charge de gommer les différences entre les navigateurs et tout se passe en général comme le
dit le code Java.
Le problème est qu'en descendant au niveau du code HTML on peut se retrouver face à une incompatibilité
entre les navigateurs. C'est ce que j'ai constaté dans ce programme : il se trouve que mes
cellules sont affichées avec des boutons
<button>
(comme ceci :
).
Or, Firefox ne gère pas les clics sur les boutons comme attendu :
si on clique sur la petite croix (pour supprimer l'item), la cible du clic doit être l'image
<img>
, c'est ce qui se passe sur
Chrome, Safari, etc, mais pas Firefox : la cible du clic dans Firefox est le <button>
. J'ai bien essayé
d'enregistrer un événement sur l'image en plus du bouton, rien n'y fait ; dès lors, comment faire pour distinguer un clic
sur le bouton d'un clic sur l'image ? Avec un bouton, on ne peut pas.
Vous pouvez tester ce défaut en visitant cette page avec 2 navigateurs différents (dont Firefox).
Je dois reconnaître que trouver la cause de ce problème telle qu'exposée ci-dessus n'a pas été immédiat. J'ai d'abord suivi une fausse piste croyant que les événements de souris n'étaient pas capturés correctement (voir ce qui concerne "event bubbling"). Il n'en est rien.
Je dois reconnaître que trouver la cause de ce problème telle qu'exposée ci-dessus n'a pas été immédiat. J'ai d'abord suivi une fausse piste croyant que les événements de souris n'étaient pas capturés correctement (voir ce qui concerne "event bubbling"). Il n'en est rien.
La solution adoptée est très simple : au lieu de recourir au
<button>
dans le template HTML,
je l'ai remplacé par un <div>
dont le contenu est construit avec le ButtonCell
de Sencha qui
lui-même n'est pas basé sur du <button>
:
@Template("<div data-index=\"{3}\">{0} " + "<span style=\"{2} border: 1; display: inline-block;" + " width: 20px ;\"> </span>" + " {1}$ <img src=\"cross.png\" " + " data-close-index=\"{3}\"></div>") SafeHtml render(String gangster, int price, SafeStyles bgStyle, int index);
La cellule devient :
ButtonCell<Gangster> button = new ButtonCell<Gangster>(); @Override public void render(Cell.Context context, List<Gangster> value, SafeHtmlBuilder sb) { if (value == null) { return; } int i = 0; SafeHtmlBuilder sb2 = new SafeHtmlBuilder(); for (Gangster gangster: value) { int price = gangster.getName().length(); sb2.append( SafeHtmlUtils.fromSafeConstant( "<div style=\"display: inline-block; " + "padding: 1px 3px 0;\">")); button.setHTML( GangstersTemplates.INSTANCE.render( gangster.getName(), price, getBackgroundStyle( gangster.getAptitude()), i++)); button.render(context, gangster, sb2); sb2.append( SafeHtmlUtils.fromSafeConstant( "</div>")); } sb.append( GangstersTemplates.INSTANCE.renderAll( sb2.toSafeHtml())); }
@Template("<div style=\"white-space: normal;\">{0}</div>") SafeHtml renderAll(SafeHtml content);
Dans le code ci-dessus, la variable
button
(de type ButtonCell
)
sert de modèle à chaque Gangster (dans l'itération) : on met dans le contenu du bouton le HTML
produit, puis on demande au bouton de se dessiner en HTML. Dans le résultat global, il n'y
a plus de <button>
.
Le code est presque complet, il suffit d'ajouter quelques arrangements et fonctionnalités
comme par exemple supprimer tous les gangsters sélectionnés à l'aide d'un menu contextuel ;
ces aspects étant triviaux, je vous laisse consulter le détail dans le code source du projet sous Github.
Conclusion
Faut-il généraliser ? La question est légitime : à la vue du code, on comprend qu'une certaine généricité faciliterait la réutilisabilité.
Cependant, avec les templates qui doivent être sur-mesure et la personnalisation qui doit être apportée (car tous les comportements implémentés
ici ne sont pas universels mais spécifiques) on peut légitimement se demander si ça vaut le coup.
De mon point de vue, non.
Parfois, il vaut mieux simplement adapter une bonne recette pour obtenir exactement l'effet souhaité que d'essayer de tordre une usine à gaz compliquée à comprendre et à mettre en œuvre.
Voilà, vous disposez maintenant de tout le nécessaire pour organisez vos prochains braquages avec succès !
Semantic Mismatch ERR-06 : ArgumentNumberException DORMIR accept only a single OREILLE -- See log for details
Aucun commentaire:
Enregistrer un commentaire