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

ÉcranLu depuisFonction
Le menucard.fronds[].doors[]une porte sans schéma n'est pas du mobilier
Les colonnes d'une listeVisibility.output + le shapetableColumnsOf
Les champs d'un formulaireVisibility.input + l'axe lifecycleformFieldsOf
Ses bornesshapeminlength, max, patternfield.attrs
L'identité d'une lignele rôle primaryFieldSet.of(fields).primary
Les boutonsdoor.opspas d'op create, pas de bouton créer
Un refusVALIDATION_FAILEDerrorsByField, champ par champ
La topologierpc.topologyle 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/admin et 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.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.