Étendre l'admin

Ajouter une page, une entrée de navigation ou un widget au tableau de bord, sans publier de package.

L'admin s'étend par un registre runtime : le core expose des fonctions d'enregistrement, le projet les appelle au boot. Aucune modification du core n'est nécessaire.

Ce guide couvre les extensions in-projet, écrites directement dans l'application. Pour distribuer la même extension en package npm réutilisable, voir Créer un module : l'API d'enregistrement est identique, seul le câblage change.

Le fichier d'extensions

Regrouper tous les enregistrements dans un point d'entrée unique, importé une seule fois depuis le layout racine. Les enregistrements se font par effet de bord à l'import.

// app/lrvo.extensions.js
import './admin/orders/ordersWidget.server.js';
import './admin/orders/ordersWidget.client.js';
import {
  registerNavSection, registerNavItem, registerPage, registerPageMeta,
} from '@lrvo/core/features/admin';
import OrdersPage from './admin/orders/OrdersPage.js';

registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'PackageIcon', order: 30 });

registerNavItem({
  id: 'orders',
  label: 'Commandes',
  icon: 'PackageIcon',
  href: '/admin/orders',
  sectionId: 'commerce',
  permission: 'orders.view',
});

registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
registerPageMeta({ slug: 'orders', title: 'Commandes', description: 'Suivi des commandes' });
// app/layout.js
import './lrvo.extensions';

Ajouter une page

import { registerPage } from '@lrvo/core/features/admin';
import OrdersPage from './OrdersPage.js';

registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });

La page est rendue sous /admin/orders. Le shell résout le composant à partir du slug dans les paramètres de route.

Utiliser AdminHeader pour l'en-tête, afin que la page ait la même structure que les pages intégrées.

'use client';
import { AdminHeader, } from '@lrvo/core/features/admin/components';
import { Button } from '@lrvo/core/shared/components';

export default function OrdersPage() {
  return (
    <div className="flex flex-col gap-6">
      <AdminHeader
        title="Commandes"
        description="Commandes reçues."
        action={<Button href="/admin/orders/new">Créer une commande</Button>}
      />
      {/* … */}
    </div>
  );
}

Sur une page de formulaire, remplacer action par backHref pour afficher un bouton retour.

Titre d'onglet

registerPage s'exécute dans le bundle navigateur. Le generateMetadata de Next.js, lui, tourne côté serveur, avant : il ne verra jamais un titre déclaré côté client.

Enregistrer donc les métadonnées séparément, depuis un fichier neutre, sans 'use client' :

import { registerPageMeta } from '@lrvo/core/features/admin';

registerPageMeta({ slug: 'orders', title: 'Commandes', description: 'Suivi des commandes' });

Ajouter une entrée de navigation

Une section regroupe plusieurs items. Une section qui n'en contient qu'un s'affiche comme un lien direct portant le titre de la section, sans chevron.

registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'PackageIcon', order: 30 });

registerNavItem({
  id: 'orders',
  label: 'Commandes',
  icon: 'PackageIcon',
  href: '/admin/orders',
  sectionId: 'commerce',
  order: 10,
  permission: 'orders.view',
});

Utiliser order plutôt que de compter sur l'ordre des appels : l'ordre d'enregistrement dépend de l'ordre d'import, qui n'est pas stable.

Le champ permission masque l'entrée pour qui ne possède pas la clé. Si tous les items d'une section sont masqués, la section disparaît aussi.

Sous-routes et fil d'Ariane

L'item actif est celui dont le basePath matche le préfixe le plus long du pathname. Le basePath est déduit du href : si href finit par /list, le suffixe est retiré, sinon basePath = href. Le préciser explicitement seulement quand cette déduction ne suffit pas.

Sur une sous-route /new ou /edit/:id, le fil d'Ariane ajoute automatiquement « Nouveau » ou « Modification ». Pour personnaliser ces libellés :

registerPage({ slug: 'orders:new',  breadcrumbLabel: 'Nouvelle commande' });
registerPage({ slug: 'orders:edit', breadcrumbLabel: 'Modifier la commande' });

Ajouter un widget

Un widget a deux moitiés : un fetcher serveur qui collecte les données, un composant client qui les affiche. Les deux partagent le même identifiant, et la même permission.

// app/admin/orders/ordersWidget.server.js
import { registerWidgetFetcher } from '@lrvo/core/features/admin';
import { countOrders } from './orders.server.js';

registerWidgetFetcher('orders', async () => ({ total: await countOrders() }), { permission: 'orders.view' });
// app/admin/orders/ordersWidget.client.js
'use client';
import { registerWidget } from '@lrvo/core/features/admin';
import { StatCard } from '@lrvo/core/shared/components';

function OrdersWidget({ data, loading }) {
  return (
    <StatCard
      title="Commandes"
      value={loading ? '-' : String(data?.total ?? 0)}
      loading={loading}
    />
  );
}

registerWidget({ id: 'orders', Component: OrdersWidget, order: 100, permission: 'orders.view' });

Le composant reçoit data, le retour du fetcher, et loading. Si le fetcher échoue, data vaut null et loading reste false. Traiter ce cas explicitement, sinon le widget affiche un chargement qui ne finit jamais. Rendre null est une réponse acceptable : la tuile disparaît proprement, sans laisser de trou.

Une tuile qui ne sait dire que « tout va bien » gagne souvent à ne rien dire. Les tuiles health et update du core rendent null tant qu'il n'y a rien à signaler, et n'apparaissent qu'en cas d'anomalie ou de mise à jour en attente. Une tuile permanente dont la valeur ne change jamais cesse d'être lue, précisément avant le jour où elle change. Réserver l'affichage permanent à ce qui se consulte : un décompte, un montant, une liste.

Une tuile de ce genre se déclare avec alert: true, et son composant lit la troisième prop, sparse :

function StockWidget({ data, sparse }) {
  if (!data) return null;
  // Rien en rupture, et le tableau de bord a de quoi remplir : se taire.
  if (data.outOfStock === 0 && !sparse) return null;
  return <Card /* … */ />;
}

registerWidget({ id: 'stock', Component: StockWidget, order: 100, alert: true });

sparse vaut true quand le tableau de bord porte moins de trois tuiles permanentes. Sans ce garde, un tableau de bord où tout va bien se réduit à une page vide. Le déclarer sert aussi au décompte : une tuile alert n'y entre pas. Voir features/admin.

Une tuile ne se dessine pas avec Card réglé à la main. StatCard couvre le chiffre unique. Au-delà, une liste ou des lignes chiffrées, reprendre le gabarit des tuiles existantes : <Card padding="none" spacing="none" className="px-5 py-4">, puis un libellé en text-[11px] font-medium uppercase tracking-[0.04em]. Ni padding, ni title : la gouttière du tableau de bord n'est aucun cran de l'échelle de Card, et le title intégré rend un titre de 14 px suivi d'un filet, là où la grille attend un libellé en petites capitales. Les deux se voient au premier coup d'œil, mais seulement côte à côte dans un tableau de bord qui tourne, jamais au build. Voir Gouttière de Card.

La permission se déclare des deux côtés. Sur le composant, elle masque la tuile ; sur le fetcher, elle empêche la collecte. Sans la seconde, la charge utile part quand même dans la source de la page pour quiconque atteint /admin. Les deux ratés possibles sont muets : un identifiant qui diverge donne une tuile dont data reste nul pour toujours, une permission qui diverge donne soit une tuile vide, soit des requêtes payées pour personne. Ni le build ni le linter ne les voient, et un administrateur qui détient toutes les permissions ne les verra pas non plus en testant. Déclarer les deux moitiés à partir d'un même objet est le moyen simple de n'avoir rien à faire concorder :

// une table neutre, lue par les deux moitiés
export const ORDERS_WIDGET = { id: 'orders', order: 100, permission: 'orders.view' };

// serveur
registerWidgetFetcher(ORDERS_WIDGET.id, fetchOrders, { permission: ORDERS_WIDGET.permission });

// client
registerWidget({ ...ORDERS_WIDGET, Component: OrdersWidget });

Les tuiles du core ferment la marche, aux ordres 200 à 250 : ce qui est propre au produit passe devant ce qui est commun à toutes les installations. Prendre order: 100 à 190 pour un widget de projet, ce qui le place après les tuiles des modules et devant celles du core. Deux widgets de même ordre se départagent par l'ordre d'import, qui n'est pas stable : choisir un ordre libre plutôt que de compter là-dessus. Voir features/admin.

Ajouter un élément à la barre supérieure

registerTopbarItem pose un composant à gauche du menu utilisateur, une cloche de notifications par exemple. L'appel se fait côté client, comme registerWidget, et l'élément existe dans les deux coquilles de l'admin. Il vit sur la barre : ses couleurs sont les jetons chrome-*. Voir Admin.

Déclarer une permission

Une permission utilisée dans registerNavItem ou registerWidget doit exister dans le catalogue pour être attribuable depuis /admin/roles. En extension in-projet, l'enregistrer au boot :

import { registerPermissions } from '@lrvo/core/users';

registerPermissions([
  { key: 'orders.view',   name: 'Voir les commandes',  description: 'Consultation', group_name: 'Commerce' },
  { key: 'orders.manage', name: 'Gérer les commandes', description: 'CRUD',         group_name: 'Commerce' },
]);

Puis relancer npx lrvo-db init pour les attribuer au rôle admin. Voir Permissions et rôles.

Pièges

Ne pas importer un barrel serveur depuis un fichier 'use client'. @lrvo/core/features/admin est neutre et donc sûr, mais @lrvo/core/users ou @lrvo/core/database ne le sont pas. Voir Points d'entrée.

Ne pas lire process.env dans un composant client. Une variable sans préfixe NEXT_PUBLIC_ retourne undefined sans erreur. Utiliser useLrvoConfig().

Toute navigation doit être un <a href>. Un router.push programmatique n'est pas intercepté par la garde des modifications non enregistrées. Voir Hooks.

Quand passer en module

Dès que l'extension doit servir dans un deuxième projet. Le code d'enregistrement est identique, il change seulement de point d'entrée. Voir Créer un module.

Sur cette page