Back-office
Un back-office pose quatre questions : quels sujets existent, ce qu'une ligne affiche, ce
qu'un formulaire fournit, et quels verbes sont permis. La carte d'identité répond déjà
aux quatre, donc @fougere/admin ne génère rien et ne déclare rien — il lit
rpc.discover au chargement et rend la réponse.
C'est là que ça diffère d'un CMS. Payload, Strapi et Directus vendent la dérivation d'un back-office à partir d'une déclaration de contenu ; ici la déclaration était déjà là, à faire cinq autres métiers, et le panneau est sa sixième projection.
pnpm add @fougere/admin react-admin
// app/admin.tsx — le fichier entier
import { FougereAdmin } from '@fougere/admin/react';
export default () => <FougereAdmin />;
Trois lignes, toutes les entités, pour toujours. Un nouveau
class Invoice extends entity({…}) apparaît dans le menu au chargement suivant, parce que
la carte a gagné une porte — pas parce qu'un fichier a été écrit.
D'où chaque écran est lu
| Écran | Lu depuis | Fonction |
|---|---|---|
| Le menu | card.fronds[].doors[] | une porte sans schéma n'est pas du mobilier |
| Les colonnes d'une liste | Visibility.output + le shape | tableColumnsOf |
| Les champs d'un formulaire | Visibility.input + l'axe lifecycle | formFieldsOf |
| Ses bornes | shape → minlength, max, pattern | field.attrs |
| L'identité d'une ligne | le rôle primary | FieldSet.of(fields).primary |
| Les boutons | door.ops | pas d'op create, pas de bouton créer |
| Un refus | VALIDATION_FAILED | errorsByField, champ par champ |
| La topologie | rpc.topology | le seul écran auquel la carte ne répond pas |
tableColumnsOf et formFieldsOf sont duales l'une de l'autre, et c'est la paire que
Formulaires utilise déjà — l'une lit ce qui peut sortir, l'autre
ce qu'un client peut fournir. Le panneau n'ajoute aucune troisième lecture.
Le gradient l'atteint
Le panneau parle au point d'appel et ne connaît rien d'autre. Donc ceci ne change rien pour lui :
// fougere.config.ts
remotes: { billing: 'https://billing.internal' }
La porte invoice part dans un autre process, la carte l'annonce toujours, le même build
rend les mêmes écrans. Un CMS headless ne peut pas faire ça — son back-office est lié à
sa propre API par construction, et la frontière de process est le produit.
Les facettes se déclarent, ne se devinent pas
Certaines choses qu'un rendu veut ne sont pas dans le shape. oneOf('draft', 'published')
dit que l'ensemble est clos ; il ne dit jamais quel membre signifie publié. C'est une
vraie affirmation sur un domaine, et c'est un humain qui la fait :
import { defineAdminExtension } from '@fougere/admin';
defineAdminExtension({
resource: 'post',
facets: {
editorial: {
title: 'title',
state: { field: 'status', draft: ['draft'], published: ['published'] },
},
},
})
Rien n'est inféré, et c'est délibéré. Une version antérieure lisait les noms de champs —
title, status, ['draft', 'pending', 'review'] — et une entité qui écrivait titre ou
brouillon perdait sa facette sans un message. Fougere reconnaît un champ à sa forme,
jamais à un mot, et une mauvaise réponse silencieuse est pire qu'une absence. Sans aucune
facette déclarée, le tableau dérivé reste tout le back-office.
Les extensions sont des deltas
Une extension ne nomme que ce qu'elle change. Elle ne fige jamais une ressource, donc un champ ajouté à l'entité demain apparaît quand même :
defineAdminExtension({
resource: 'post',
label: 'Articles',
fields: { internalNote: { hidden: true }, title: { label: 'Titre' } },
operations: { publish: { confirm: 'Publier cet article ?' } },
})
Pour plus fin, un rendu remplace un champ, et toutes les props de React Admin marchent
toujours — theme, layout, authProvider, i18nProvider, dashboard :
<FougereAdmin
renderers={{ fields: { 'user.role': ({ column }) => <ChipField source={column.name} /> } }}
resourceComponents={{ post: { list: MyPostList } }}
/>
Il ne parle aucune langue
Chaque phrase visible passe par une clé : fougere.admin.* pour les widgets du panneau,
entity.field pour un champ — la convention qu'énonce formFieldsOf, avec le nom dérivé
comme repli. Un back-office non traduit est donc en anglais, et un i18nProvider qui
remplit ces clés est toute la traduction.
polyglotI18nProvider(() => ({
...frenchMessages,
fougere: { admin: { overview: { title: "Vue d'ensemble" } } },
post: { title: 'Titre', status: 'Statut' },
}), 'fr')
La seule page qui n'est pas une porte
Chaque écran ci-dessus rend une porte. Topologie rend l'app :
catalog ici 1 entité · 1 porte → blog (12)
blog ailleurs sa forme est publiée par le processus qui la possède ← catalog (12)
Elle est lue depuis rpc.topology, pas depuis la carte — la carte dit ce qu'un processus
héberge, celle-ci dit dans quelle forme il est : quels Fronds tournent dans le processus
auquel le panneau parle, lesquels ont répondu d'ailleurs, et chaque chemin d'appel observé
entre les deux. Rien là-dedans n'est déclaré : un Frond est distant parce qu'il a répondu,
jamais parce que remotes: l'a dit.
Cette op est servie par @fougere/observability, donc un
panneau branché sur une app qui ne s'observe pas reçoit un refus — et la page dit quelles
deux lignes manquent. L'entrée de menu reste dans les deux cas : la cacher cacherait le seul
endroit qui peut expliquer l'absence.
Ce qu'il ne fera pas
- Retirer une ressource ou une opération. Cacher une porte dans le navigateur pendant
que la façade la sert encore se lit comme un droit et n'applique rien. Ce qu'une porte
sert à un public donné, c'est ce qu'énonce une surface —
pointez le panneau sur
/_fougere/call/adminet la carte répond restreinte. Un champ peut toujours être caché : ça ne change rien à ce qui est joignable. - Écrire hors de la façade. Il tourne dans un navigateur, donc il ne peut atteindre que le point d'appel — juges, presenters et middlewares sont sur le chemin de chaque lecture et de chaque écriture qu'il fait. C'est une conséquence, pas une précaution.
- Agréger. Le seul agrégat du port Storage est
count, donc le tableau de bord compte des lignes et le dit. « Le chiffre d'affaires sur trente jours » est une opération à écrire, dans un repository ; le panneau rend son résultat, il n'invente pas la requête. - Posséder vos pages. C'est un panneau pour qui administre les données. Les écrans de l'application restent les vôtres, bâtis sur queries et commands.
Un manque connu
Le total d'une liste ne survit pas au fil : ListResult étend Array, et
JSON.stringify jette les propriétés d'un tableau. Le provider n'en dépend pas — il
demande une ligne de plus que la page et renvoie pageInfo — donc la pagination est
exacte. Un compte de lignes dans un en-tête peut être absent sous un découpage.
Suite : Le gradient — déplacer une Frond hors process.