3. Les écrans
Un écran est un fichier JSON dans views/, nommé <modèle>.<genre>.json. Pas un gabarit, pas
un composant, pas de CSS.
Huit genres se déclarent. Les voici tous, sur le module de bibliothèque.
La liste
{ "model": "library_title", "kind": "list", "fields": ["name", "author", "genre", "published_year", "isbn"]}model: library_titleviews: list: { columns: [name, author, genre, published_year, isbn] }Le fichier YAML rassemble toutes les vues d’un modèle ; le JSON en met une par
fichier. Et la clé s’appelle columns ici, fields là : les deux formats ne se
sont pas mis d’accord, et c’est signalé.
view library_title list { columns name, author, genre, published_year, isbn}Le même écran, une ligne par réglage.

Vous avez déclaré cinq colonnes. La pagination, le tri, le filtre, la sélection multiple, le sélecteur de modèle, le bouton « New » et le sélecteur de genre de vue viennent du moteur.
Le formulaire
{ "model": "library_title", "kind": "form", "sections": [ { "title": "section.work", "fields": ["name", "author", "genre", "published_year"] }, { "title": "section.reference", "fields": ["isbn", "summary"] } ]}model: library_titleviews: form: sections: - { title: section.work, fields: [name, author, genre, published_year] } - { title: section.reference, fields: [isbn, cover, summary] }view library_title form { section section.work fields name, author, genre, published_year, summary section section.reference fields isbn, cover}Le même écran, une ligne par réglage.

Chaque champ a pris le contrôle qui correspond à son type : liste déroulante pour la sélection, champ numérique pour l’entier, zone de texte pour le résumé. Vous n’avez rien choisi.
layout accepte cards, accordion ou tabs si vous voulez une autre disposition des
sections.
Le kanban
{ "model": "library_loan", "kind": "kanban", "groups_by": "state", "fields": ["copy_id", "member_id", "due_on"]}model: library_loanviews: kanban: { groups_by: state, card_fields: [copy_id, member_id, due_on] }view library_loan kanban { groups by state card copy_id, member_id, due_on}Le même écran, une ligne par réglage.

groups_by prend un champ de sélection ou un many2one : ses valeurs deviennent les
colonnes. Les cartes se déplacent d’une colonne à l’autre, et le déplacement écrit le champ.
Remarquez que les en-têtes portent les libellés traduits (« Out », « Returned », « Lost ») et leur compte, et que les cartes affichent le nom calculé de l’exemplaire plutôt qu’un identifiant.
Le même genre, sur le catalogue, groupé par genre littéraire :

Le calendrier
{ "model": "library_loan", "kind": "calendar", "date_field": "due_on", "fields": ["copy_id", "member_id"]}model: library_loanviews: calendar: { date_field: due_on, card_fields: [copy_id, member_id] }view library_loan calendar { date due_on card copy_id, member_id}Le même écran, une ligne par réglage.

Un seul champ obligatoire : la date qui place l’enregistrement. Ici, la date de retour prévue, ce qui donne au bibliothécaire la vue qui l’intéresse.
La frise
{ "model": "library_loan", "kind": "timeline", "start_field": "taken_on", "end_field": "due_on", "label_field": "member_id", "group_by": "state", "default_zoom": "week"}model: library_loanviews: timeline: start_field: taken_on end_field: due_on label_field: member_id group_by: state default_zoom: weekview library_loan timeline { start taken_on end due_on label member_id groups by state zoom week}Le même écran, une ligne par réglage.

Chaque enregistrement devient une barre entre deux dates. group_by fabrique les lignes,
default_zoom choisit l’échelle initiale parmi jour, semaine, mois, trimestre et année.
La frise accepte aussi progress_field (une barre de progression dans la barre),
dependency_field (des flèches entre les barres) et milestone_field (des jalons).
Le tableau croisé
{ "model": "library_loan", "kind": "pivot", "row_field": "state", "col_field": "member_id", "agg": "count", "chart": "bar"}model: library_loanviews: pivot: { row_field: state, col_field: member_id, agg: count, chart: bar }view library_loan pivot { row state column member_id agg count}Le même écran, une ligne par réglage.

row_field seul suffit ; col_field ajoute la seconde dimension. agg accepte count, sum,
avg, min et max, et measure_field désigne alors le champ mesuré. chart affiche en plus
un graphique.
Le tableau de bord
{ "model": "library_title", "kind": "dashboard", "title": "library_title.dashboard.title", "kpis": [ { "key": "titles", "label": "library_title.dashboard.titles", "op": "count" } ], "charts": [ { "key": "by_genre", "label": "library_title.dashboard.by_genre", "chart_type": "column", "groupby": "genre", "op": "count" } ]}Des indicateurs et des graphiques, sur un seul modèle. op accepte count, sum et avg ;
chart_type accepte column, bar, line, area, pie et donut.
Notez que label est une clé de traduction, ici comme partout.

Les libellés des barres viennent des choix du champ, traduits : « Fiction », pas
fiction. C’est le même mot que la liste et le kanban affichent pour la même colonne, et
c’était faux jusqu’à ce que cette capture soit prise.
La charge
{ "model": "library_loan", "kind": "workload", "group_by": "member_id", "start_field": "taken_on", "end_field": "due_on", "measure_field": "fine_amount", "capacity_per_period": 40, "default_zoom": "week"}Une frise qui cumule : elle répond à « qui est surchargé sur quelle période ».
capacity_per_period trace le seuil au-delà duquel la période est en dépassement.
La galerie
Celle-ci ne se déclare pas : elle apparaît dès que le modèle a une image.
registry.register_model( "library_title", module="library", fields=[ ..., FileField( name="cover", allowed_extensions=("png", "jpg", "jpeg", "webp", "svg"), max_size_bytes=5 * 1024 * 1024, ), ], # This one line is what makes the gallery view exist. image_field="cover",)Pas de fichier de vue, pas de configuration : image_field désigne le champ qui porte l’image
du modèle, et le moteur ajoute la galerie à la liste des vues offertes.

La même image sert aussi d’avatar ailleurs : dans un sélecteur de relation, dans un en-tête d’enregistrement, partout où le produit montre un ouvrage.
Le tableau de bord d’accueil
L’écran d’accueil n’appartient à personne : c’est la page qu’un utilisateur voit en arrivant, et les widgets y viennent de tous les modules installés. Sa disposition est une préférence de l’utilisateur, donc un module ne peut pas la lui imposer. Il peut en revanche proposer un point de départ, et c’est ce que voit quelqu’un qui n’a encore rien réglé.
modules/library/home.json :
{ "widgets": [ { "kind": "metric", "model": "library_loan", "op": "count" }, { "kind": "metric", "model": "library_title", "op": "count" }, { "kind": "chart", "model": "library_title", "op": "count", "group": "genre", "display": "column" }, { "kind": "chart", "model": "library_loan", "op": "count", "group": "state", "display": "donut" } ]}Rien à appeler : le noyau lit ce fichier au chargement du module, comme il lit
onboarding.json.

Les deux règles qui rendent ça acceptable
C’est un défaut, jamais une écriture. Le fichier n’est pas versé dans la préférence de qui que ce soit. Il sert uniquement tant que l’utilisateur n’a rien exprimé.
Dès qu’il arrange son tableau de bord, le sien est le seul qui compte. Une montée de version de votre module ne doit pas réorganiser un écran que quelqu’un a mis en place. C’est la même règle qu’un flux livré à l’étape 12.
Les genres de widget
metric (un nombre), chart (une répartition, avec group), timeseries (une évolution,
avec date et interval), et les widgets système notifications, tasks et ai_briefing,
qui parlent de l’utilisateur et ne portent donc pas de modèle.
Un widget mal déclaré est refusé au chargement, avec le module nommé. Ce n’est pas un
excès de zèle : un widget sans modèle interroge /undefined et fait tomber toute la grille,
donc le frontend l’écarte — ce qui veut dire qu’une faute de frappe n’apparaîtrait tout
simplement pas, et que personne ne saurait pourquoi.
La carte
Celle-ci n’est ni un genre déclarable, ni un genre qui apparaît tout seul : c’est une vue custom. Le principe est différent et vaut d’être compris, parce qu’il ouvre la porte à tout ce que le moteur générique ne sait pas faire.
Un genre de vue est rendu par le moteur à partir d’un JSON. Une vue custom, elle, désigne un composant générique du frontend et lui passe sa configuration. Le composant existe déjà dans le produit ; votre module dit seulement « pour ce modèle, sur cette surface, prends celui-là, avec ces champs ». Toujours aucune ligne de frontend à écrire, et toujours rien de spécifique à votre module côté écran.
La bibliothèque veut voir où habitent ses adhérents, pour savoir où poser une boîte de retour.
Deux champs sur le modèle :
DecimalField(name="lat", nullable=True, precision=9, scale=6),DecimalField(name="lng", nullable=True, precision=9, scale=6),Six décimales font une dizaine de centimètres, bien plus qu’une bibliothèque n’en demande, et c’est la précision qu’une colonne de coordonnées porte d’ordinaire.
Puis un fichier views/library_member.custom.json :
[ { "model": "library_member", "surface": "map", "component": "map", "config": { "lat_field": "lat", "lng_field": "lng", "label_field": "name", "subtitle_field": "card_number", "state_field": "status" } }]Et une ligne dans le register() du module, à côté de celle qui charge les vues :
register_custom_views_from_dir(_MODULE_DIR / "views")
Le bouton Carte est apparu à côté de la liste et de l’index A–Z. Le compteur dit combien d’enregistrements sont placés : les adhérents sans coordonnées ne disparaissent pas en silence, ils sont comptés à part. Un clic sur un point ouvre la fiche.
Le fond de carte n’est pas dans votre module non plus : il vient de
GET /api/v1/geo/map-config, réglable par l’instance. Par défaut ce sont les tuiles
OpenStreetMap, et l’attribution en bas à droite est obligatoire, elle n’est pas décorative.
Le tableau des huit
| Genre | Obligatoire | Optionnel |
|---|---|---|
list | fields | name |
form | fields | sections, layout |
kanban | groups_by, fields | name |
calendar | date_field | fields |
timeline | start_field, end_field | label_field, group_by, progress_field, dependency_field, milestone_field, default_zoom |
workload | group_by, start_field, end_field | measure_field, capacity_per_period, default_zoom |
pivot | row_field | col_field, measure_field, agg, chart |
dashboard | kpis, charts | title |
Deux genres de plus n’ont pas de fichier de déclaration : le moteur les propose quand le modèle s’y prête.
| Genre | Apparaît quand |
|---|---|
gallery | Le modèle déclare un image_field. |
alphabetic | Le modèle a un champ texte servant de titre. |
Et la carte n’est ni l’un ni l’autre : c’est une vue custom, déclarée dans un fichier
*.custom.json, qui désigne un composant du frontend au lieu d’être rendue par le moteur.
Ce que le serveur envoie vraiment
C’est ici que le modèle prend tout son sens.
curl -H "Authorization: Bearer $TOKEN" \ "http://127.0.0.1:8080/api/v1/views/library_loan/effective?kind=list"{"model":"library_loan","kind":"list","body":{"columns":[ {"name":"copy_id","label":"Copy id","label_i18n_key":"library_loan.copy_id", "type":"many2one","required":true,"target_model":"library_copy","origin":"system"}, {"name":"state","label":"State","label_i18n_key":"library_loan.state", "type":"selection","options":[ {"value":"out","label":"Out","i18n_key":"library_loan.state.out"}]}]}}Le serveur ne renvoie pas seulement des données : il renvoie le type de chaque colonne, son libellé, sa clé de traduction, sa cible de relation et ses options. L’administration n’a besoin de connaître aucun nom de champ à l’avance.
C’est pour cette raison qu’un module apparaît sans toucher au frontend, et c’est aussi pour cette raison qu’un module ne peut pas imposer sa propre mise en page : il décrit, le moteur rend.
Et ensuite
Le rendre visible. Parce qu’à ce stade, votre module a quatorze écrans que personne ne trouvera.