Aller au contenu

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"]
}

La liste du catalogue : titre, auteur, genre, année, ISBN

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"] }
]
}

Le formulaire d'une œuvre, en deux sections numérotées

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"]
}

Le kanban des emprunts : trois colonnes Out, Returned, Lost, avec le compte et les cartes

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 kanban du catalogue, groupé par genre

Le calendrier

{
"model": "library_loan",
"kind": "calendar",
"date_field": "due_on",
"fields": ["copy_id", "member_id"]
}

Le calendrier des retours attendus, avec navigation par mois

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"
}

La frise des emprunts, groupée par état, avec les niveaux de zoom

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"
}

Le tableau croisé des emprunts, état par adhérent

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.

Le tableau de bord du catalogue : le nombre de titres, et la répartition par genre

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 galerie du catalogue : une affiche pleine par ouvrage, titre et auteur dessus

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.

L'écran d'accueil : les quatre widgets proposés par le module de bibliothèque

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")

La carte des adhérents : quatre points sur Paris, le compteur « 4 on the map », et le sélecteur de vue à droite

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

GenreObligatoireOptionnel
listfieldsname
formfieldssections, layout
kanbangroups_by, fieldsname
calendardate_fieldfields
timelinestart_field, end_fieldlabel_field, group_by, progress_field, dependency_field, milestone_field, default_zoom
workloadgroup_by, start_field, end_fieldmeasure_field, capacity_per_period, default_zoom
pivotrow_fieldcol_field, measure_field, agg, chart
dashboardkpis, chartstitle

Deux genres de plus n’ont pas de fichier de déclaration : le moteur les propose quand le modèle s’y prête.

GenreApparaît quand
galleryLe modèle déclare un image_field.
alphabeticLe 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.

Fenêtre de terminal
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.