Apparence
Les hooks & slots de vue
Un hook (ou slot) est un point d'insertion déclaré dans une vue : un package y greffe son contenu — un onglet sur la fiche produit, un bandeau dans le front — sans forker la vue.
Le problème
Le package « promotions » veut ajouter un onglet « Promotions » à la fiche produit du back-office. Surcharger toute la vue produit pour ça serait fragile (perdu à chaque évolution du cœur). Il faut un point d'ancrage que le cœur expose et que le package remplit.
Le principe
Le cœur (ou le thème) place un <x-hook name="…" :context="[…]"/> dans sa vue. Les packages enregistrent une vue partielle pour ce hook via le ViewHookRegistry. Au rendu, le composant interroge for($hook) et @include chaque vue enregistrée, triée par priorité croissante (à priorité égale, l'ordre d'enregistrement départage), en lui passant le contexte du hook.
Greffer une vue sur un hook
Dans le boot() du provider :
php
use Slab\Framework\Core\View\Contracts\ViewHookRegistry;
$hooks = $this->app->make(ViewHookRegistry::class);
$hooks->add('back.products.edit.tabs', 'promotion::back.products.tab', priority: 20);
$hooks->add('back.products.edit.sections', 'promotion::back.products.section');La vue partielle est incluse (@include) avec le contexte passé au hook : ses variables — ici $product — sont directement disponibles. Ce n'est pas un composant Blade, donc pas de @props.
blade
{{-- promotion::back.products.tab --}}
<x-back.header.tab href="#promotions-section">{{ __('Promotions') }}</x-back.header.tab>Un onglet contribué n'est qu'un lien : la section correspondante porte le data-tab qui lui répond.
blade
{{-- promotion::back.products.section --}}
<x-back.section data-tab="promotions-section" :title="__('Promotions')">
{{-- … $product est disponible ici … --}}
</x-back.section>Les slots du front
Le front expose des slots garantis, publiés par la classe Slab\Framework\Core\View\FrontSlotContract : groups() les rend groupés par zone d'écran (layout, header, catalog, cart, checkout, content, account, forms), names() à plat. Un thème qui se prétend complet doit les exposer ; les packages les remplissent de la même façon que les hooks du back-office.
| Slot | Emplacement |
|---|---|
front.head | dans <head> (méta, styles, balises tierces) |
front.body.start / front.body.end | ouverture et fin de <body> |
front.main.start / front.main.end | ouverture et fin du contenu principal |
front.header.start / front.header.end | entête, avant et après la navigation |
front.product.summary.after | sous le prix (stock, réassurance, note d'avis) |
front.product.form.fields | dans le formulaire d'ajout au panier — les champs contribués sont postés avec lui |
front.product.after | bas de fiche (cross-selling, avis, description longue) |
front.category.title.after | sous le titre de la catégorie (accroche, bannière) |
front.category.filters.after / front.category.products.after | page de catégorie |
front.search.results.before | au-dessus des résultats de la recherche instantanée — c'est là qu'un paquet ajoute ses propres filtres, à côté de ceux du catalogue (contexte : la recherche et sa requête) |
front.search.results.after | sous les résultats de la recherche instantanée |
front.cart.item.after | sous une ligne du panier (contexte : le produit) |
front.cart.totals.before | au-dessus des totaux (code promo, estimation) |
front.cart.totals.after / front.cart.actions | totaux et bouton de commande |
front.checkout.summary | près du récapitulatif du tunnel |
front.checkout.details | ce qu'un projet demande en plus sur la commande |
front.checkout.step.start / front.checkout.step.end | ouverture et fin de l'étape courante (contexte : l'étape) |
front.page.after | sous une page de contenu (partage, formulaire, pages liées) |
front.account.links | rubriques du compte client |
front.account.user.form.start | tête de « Mes informations » — ce qui achève de nommer le client (civilité, titre) se lit avant son nom (contexte : le client) |
front.account.user.form.end | bas de « Mes informations » — les champs contribués sur le client s'y saisissent (contexte : le client) |
front.order.details.after | sous le détail d'une commande |
front.order.selection.<exigence> | sous un choix figé d'une commande — …shipping, …payment (contexte : la commande, l'exigence, la sélection) |
front.login.form.end / front.register.form.end / front.address.form.end | bas des formulaires |
front.address.block.end | bas d'une adresse affichée (les champs contribués s'y relisent) |
Ce qu'un choix montre après la commande n'est pas ce qu'il montrait avant
front.order.selection.<exigence> s'ouvre pour chaque choix que la commande a figé, et porte le nom de son exigence : un paquet s'y branche sans connaître l'écran qui l'affiche. Son nom se dérivant de la commande, il ne figure pas dans names(), qui ne nomme que des emplacements fixes. C'est volontairement distinct de ce que l'option montre au tunnel (CheckoutOption::$content / $slot) — on y choisissait, ici on a choisi. Un formulaire de carte n'a plus rien à faire sur une confirmation, quand le reçu de sa transaction n'avait pas de sens avant ; un virement, lui, y rappelle les coordonnées telles qu'elles ont été annoncées, la sélection les ayant gardées.
slab/payment et slab/carrier s'y branchent déjà : le premier délègue à PaymentService::orderContent(), le second à CarrierService::orderContent() — nul par défaut, et le délai annoncé pour la grille du cœur.
Le titre et la description d'une page
Le <head> du thème rend une section metas quand la page en pose une, et son titre par défaut sinon. Une page qui sert une entité y place <x-front.metas :model="$entité"/> :
blade
<x-layout.front>
@section('metas')
<x-front.metas :model="$brand"/>
@endsectionLe composant n'impose ni champ ni interface : il lit meta_title, meta_description et keywords s'ils existent sur le modèle — tels qu'un paquet les y contribue — et se rabat sur le nom de l'entité, puis sur celui du site. Un référentiel ajouté demain en profite sans rien implémenter, du moment qu'il nomme ses champs comme les autres.
php
$this->app->make(ViewHookRegistry::class)->add('front.body.end', 'mon-package::tracking');Back-office et e-mails
Le back-office expose sa propre grille, publiée par BackSlotContract (groups() la range en layout et chrome, names() à plat) : back.head, back.body.start / back.body.end, back.main.start / back.main.end, plus back.header.corner (près du menu utilisateur), back.header.actions (barre d'actions de l'écran) et back.menu.end (bas du menu latéral). Elle ne fait pas partie du contrat de thème front.
Les gabarits d'e-mail ouvrent mail.body.start et mail.body.end — publiés par FrontSlotContract::mailNames(), hors contrat de thème puisqu'ils appartiennent au framework — présents dans tous les mails transactionnels : de quoi y poser des mentions légales ou un encart de campagne une fois pour toutes.
Le hook d'écran : cibler n'importe quelle page
Les slots ci-dessus nomment un emplacement de contenu. Pour cibler une page entière, il n'y a rien à déclarer : toute route nommée ouvre deux hooks, dérivés de son nom.
php
// Sur la page servie par la route `back.products.index` :
$hooks->add('back.products.index.start', 'mon-package::alerte-stock');
$hooks->add('back.products.index.end', 'mon-package::export');
// Et sur la fiche produit du thème :
$hooks->add('front.products.show.end', 'mon-package::avis');C'est le point d'extension de vue le plus général du framework : un package atteint ainsi un écran du cœur, du thème ou d'un autre package, sans que celui-ci ait eu à le prévoir. Les vues contribuées reçoivent en contexte les paramètres résolus de la route — sur back.products.edit, $product est directement disponible.
Les deux positions (start, end) encadrent le contenu de l'écran ; elles sont posées une fois pour toutes dans les layouts (<x-screen-hook position="start"/>), y compris ceux de la page de connexion du back-office. Hors d'une route nommée (rendu d'un mail, d'un job), le hook ne cible aucun écran et ne rend rien.
Exposer un hook dans votre propre thème
Un thème déclare ses points d'ancrage avec le composant <x-hook> :
blade
<head>
{{-- … --}}
<x-hook name="front.head"/>
</head>Disponibles sur tous les écrans d'édition
Chaque écran d'édition du back-office expose deux hooks :
back.<ressource>.edit.tabs— pour ajouter un onglet ;back.<ressource>.edit.sections— pour ajouter une section sous le formulaire.
où <ressource> ∈ areas, attributes, attribute_values, categories, currencies, customers, homepage, languages, order_statuses, pages, products, settings, taxes, tax_rules, team. Le contexte porte le modèle édité (ex. ['tax' => $tax] ; ['user' => $user] pour customers et team), complété des listes dont l'écran a besoin (attributes_list, taxes_list, areas_list…) et, sur attribute_values, de l'attribute parent — sauf sur settings, écran sans fiche dont le contexte est vide. Les hooks sont nommés d'après l'écran, pas la ressource : la fiche commande et la fiche panier, qui se lisent au lieu de se remplir, ouvrent donc back.orders.show.tabs / back.orders.show.sections et back.carts.show.tabs / back.carts.show.sections. Greffer un bloc sur l'écran des taxes :
php
$this->app->make(ViewHookRegistry::class)
->add('back.taxes.edit.sections', 'mon-package::taxes.bloc');Ces écrans étant tous bâtis sur la charpente déclarée (voir ci-dessous), ils ajoutent une troisième famille : back.<ressource>.edit.tab.<clé>, rendue en fin de l'onglet de cette clé (au bas de sa colonne principale quand il a une marge) — le point où ajouter des champs à un onglet existant sans toucher à ses sections. Les trois familles sont publiées par BackSlotContract::formPatterns() ({screen}.tabs, {screen}.sections, {screen}.tab.{tab}) ; les onglets contribués à {screen}.tabs prennent place dans la colonne d'onglets, à la suite de ceux du cœur.
La charpente d'un écran : le FormLayoutRegistry
Le ViewHookRegistry ajoute du contenu à un point nommé ; le FormLayoutRegistry rend la structure de l'écran manipulable. Le cœur déclare ses onglets et ses sections par clé (dans Slab\Framework\Core\View\BackFormLayouts), l'écran les rend par <x-back.form-layout screen="…" :context="[…]"/> — le composant demande au registre layout($screen, $context), qui restitue les onglets ordonnés garnis de leurs sections — plus rien n'est écrit en dur dans le blade. Tous les écrans de fiche du back-office fonctionnent ainsi, de la fiche produit à celle des valeurs d'attribut. L'écran décide où poser la charpente : dans son <form> — les champs contribués sont alors postés sans rien déclarer — ou à côté, les champs se rattachant au formulaire par leur attribut form (cas de la fiche produit, dont le formulaire vit dans la barre d'actions, et de la fiche attribut, dont les listes de valeurs et de sous-attributs gardent leurs filtres hors du formulaire). La fiche produit, par exemple :
php
$layout->tab('back.products.edit', 'general', __('Général'), priority: 10);
$layout->section('back.products.edit', 'identity', 'back.products.sections.identity', 'general', priority: 10);
$layout->section('back.products.edit', 'url', 'back.products.sections.url', 'general', priority: 20);
$layout->section('back.products.edit', 'prices', 'back.products.sections.prices', 'general', priority: 30);
$layout->section('back.products.edit', 'categories', 'back.products.sections.categories', 'general', priority: 40);
$layout->section('back.products.edit', 'stock', 'back.products.sections.stock', 'general', priority: 50);Ces clés — plus variants, la section de l'onglet du même nom — sont la prise qu'un package a sur l'écran : il les déplace, les réordonne, ou substitue sa propre vue à l'une d'elles en réenregistrant la clé (un package qui tient le stock ailleurs remplace ainsi stock). L'adresse de la page, url, est un bloc à part pour cette raison : un produit bâti sur le cœur la range avec son référencement. Les autres écrans suivent la même convention : une unique section identity sur les écrans simples, deux sur back.categories.edit (identity, url — l'adresse, à part comme sur la fiche produit), back.currencies.edit (identity, exchange) et back.tax_rules.edit (identity, areas), trois sur back.order_statuses.edit (identity, meaning, effects) — ce qu'un statut est, ce qu'il signifie pour le domaine, ce qu'il déclenche — et trois sur back.customers.edit, où account (priorité 15) s'est glissée entre identity (10) et preferences (20) : le cœur lui-même se réordonne par les trous qu'il laisse. La page de contenu (back.pages.edit), elle, se range comme une fiche produit, sur trois onglets : « Général » porte identity et publication, « Image » sa section media, « Référencement » sa section seo, URL comprise. La fiche attribut (back.attributes.edit) donne à chacune de ses listes son onglet : general (identity), puis values (« Valeurs », 20) et children (« Sous-attributs », 30), une section du même nom chacun — ces deux derniers n'apparaissant qu'une fois l'attribut enregistré.
La page d'accueil (back.homepage.edit) pousse le découpage plus loin : un onglet par bloc — banner (« Bandeau », priorité 10) et seo (« Référencement », 90), une section du même nom chacun — et l'écart entre les priorités attend les blocs qu'un projet y ajoutera.
Dès qu'un écran a de quoi naviguer, ses onglets se rangent sur le côté (<x-back.tabbed>), tous sous les yeux quel que soit leur nombre : c'est la seule disposition du back-office, et l'écran hôte n'a rien à en décider — il passe son nom et son contexte :
blade
<x-back.form-layout screen="back.homepage.edit" :context="['homepage' => $homepage]"/>Un onglet contribué, par tab() comme par le hook <screen>.tabs, rejoint la même colonne que ceux du cœur, sans avoir à savoir où elle se trouve.
La fiche commande (back.orders.show) est le premier écran qui se lit plutôt qu'il ne se remplit, et c'est là que la charpente sert le plus : le cœur n'y connaît ni transporteur ni transaction. Un seul onglet, order (« Commande »), partagé en deux colonnes : le principal — products (10), selections (40) et timeline (60) — et, en marge (aside: true), summary (10), documents (15), customer (20) et addresses (30). Une section sans rien à dire n'est pas posée : selections attend que la commande ait figé des choix (checkout_selections), documents que le contexte en porte. Les priorités laissent exprès de l'air entre les sections : slab/carrier glisse la sienne (carrier, priorité 35) entre products et selections, slab/payment la sienne (payment, priorité 45) entre selections et timeline — chacune à la place où on l'attend, sans qu'aucune vue du cœur ne bouge.
La fiche panier (back.carts.show) en reprend le dessin, et rien ne s'y modifie — un panier appartient à son client. Un seul onglet, cart (« Panier ») : au principal products (10) et selections (40), en marge summary (10), customer (20) et addresses (30). Là aussi, une section sans rien à dire n'est pas posée : selections attend les choix figés du panier (checkout_selections), addresses une adresse de livraison ou de facturation — un panier qu'on n'a pas mené jusqu'au tunnel n'en a pas.
L'écran des réglages (back.settings.edit) va au bout de l'idée : ni fiche ni liste, un contexte vide, et un onglet par sujet plutôt qu'une colonne où tout s'empile — general (« Général », priorité 10), catalog (20), registration (30), legal (« Pages légales », 35), third-party (« Services externes », 37) et notifications (40), ces cinq derniers portant une section du même nom, rendus sur le côté comme la page d'accueil : un réglage se cherche par ce qu'il touche. « Services externes » ne règle rien : il recense les services d'autres sociétés que la boutique utilise réellement — une carte, une vidéo —, à reporter dans sa politique de confidentialité et, s'il y en a une, dans sa CMP ; ce sont les briques installées qui les déclarent. L'onglet « Général » est déclaré sans que le cœur y mette rien — la charpente écarte un onglet vide, et le premier package qui y range une section le fait apparaître. Un package range la sienne comme ailleurs, et l'écran continue d'ignorer ce qu'il présente ; la charpente y étant posée dans le <form>, les champs contribués sont postés avec ceux du cœur.
Un package réorganise cet écran depuis l'extérieur : il déclare son onglet, y range ses sections, et déplace celles du cœur par leur clé.
php
use Slab\Framework\Core\View\Contracts\FormLayoutRegistry;
$layout = $this->app->make(FormLayoutRegistry::class);
$layout->tab('back.products.edit', 'pricing', __('Tarifs'), priority: 20);
$layout->section('back.products.edit', 'grids', 'mon-package::products.grids', 'pricing');
// La section « prices » du cœur rejoint l'onglet « Tarifs ».
$layout->moveSection('back.products.edit', 'prices', 'pricing');Les règles qui gouvernent le rendu :
- la clé fait foi — réenregistrer
tab()ousection()avec une clé existante remplace la déclaration : c'est ainsi qu'on renomme ou réordonne un élément du cœur ; - ordre par priorité croissante, onglets comme sections ;
when— uneClosurerecevant le contexte de l'écran conditionne l'affichage d'un onglet ou d'une section (le cœur s'en sert pour n'afficher « Variantes » que sur un produit parent enregistré) ;- les sections d'un onglet s'empilent sur toute la largeur, et chacune range ses champs dans la grille commune du back-office ; le paramètre
columnsdetab()est désormais sans effet (déprécié, retiré à la prochaine version majeure) ; aside: truerange une section dans la colonne étroite : dès qu'un onglet en porte une, il se partage en deux — le principal à gauche, sur deux tiers, le reste en marge — et chaque colonne s'empile pour son compte, sans trou en face d'un bloc haut (FormTab::hasAside()le dit,sectionsIn()rend les sections d'une colonne) ;- les onglets ne s'affichent que s'il y a de quoi naviguer — un écran à onglet unique rend ses sections seules ; les onglets apparaissent, sur le côté, dès qu'un second onglet est déclaré, ou qu'un package en contribue un au hook
<screen>.tabs; - un onglet sans section applicable est écarté — un onglet vide serait un lien vers du vide ; c'est ce qui permet de vider un onglet du cœur en déplaçant ses sections, et, à l'inverse, de déclarer un onglet d'avance qui n'apparaîtra qu'une fois garni (le « Général » des réglages) ;
moveSection()sur une clé inconnue est sans effet : un package ne casse pas l'écran parce que le cœur a renommé une section — corollaire, un déplacement joué avant la déclaration de sa cible est perdu silencieusement. L'ordre d'enregistrement des providers ne garantissant rien, contribuez depuis$this->app->booted(…)dès que vous touchez à une déclaration du cœur.
Onglet, section ou charpente ?
Sur un écran bâti sur le FormLayoutRegistry, passez par lui : c'est le seul moyen d'ordonner vos blocs parmi ceux du cœur ou de les déplacer. Les hooks …edit.tabs / …edit.sections restent la voie pour contribuer un onglet sans connaître la charpente (la section contribuée porte alors son propre data-tab), et …edit.tab.<clé> pour ajouter des champs à la fin d'un onglet existant. Sur un écran simple, une section reste le moyen le plus direct. Sans contribution, ces hooks sont invisibles (aucun changement d'affichage).
Les lignes cochées d'un listing : le BulkActionRegistry
Le ViewHookRegistry ajoute du contenu, le FormLayoutRegistry déplace des blocs ; le troisième registre de cette surface porte ce qu'un administrateur fait aux lignes cochées d'un listing. Une action groupée est déclarée — libellé, confirmation, droit exigé, exécution — dans un objet BulkAction, sous l'id du listing (products, categories…), le même que pour le DatatableExtensionRegistry.
php
use Illuminate\Database\Eloquent\Collection;
use Slab\Framework\Core\View\BulkAction;
use Slab\Framework\Core\View\BulkActionReport;
use Slab\Framework\Core\View\Contracts\BulkActionRegistry;
use Slab\Framework\Models\Product;
$this->app->make(BulkActionRegistry::class)->add('products',
BulkAction::make('sync-grids', Product::class)
->label(fn (): string => __('Synchroniser les grilles'))
->confirm(fn (): string => __('Synchroniser les grilles des lignes cochées ?'))
->priority(30)
->handle(function (Collection $products): BulkActionReport {
$affected = 0;
$errors = [];
foreach ($products as $product) {
if ($product->is_parent) {
$errors[] = __('Produit parent :id : ignoré.', ['id' => $product->getKey()]);
continue;
}
app(GridSynchronizer::class)->sync($product);
$affected++;
}
return new BulkActionReport(affected: $affected, errors: $errors);
}));Les règles qui gouvernent l'exécution :
- libellé et confirmation en
Closuredès qu'ils sont traduits : les actions se déclarent au boot des providers, avant que la locale de la requête ne soit fixée. La confirmation ne porte pas le nombre de lignes — il n'est connu qu'au clic ; authorize()— sans le droit, l'action n'est ni proposée (allowedFor()) ni exécutable ; le contrôleur retrouve l'action postée par sa clé (get()) et revérifieisAllowed()avant d'agir, car ne pas afficher une action ne protège pas d'une requête directe ;- la clé fait foi, ici aussi :
add()sur une clé déjà déclarée remplace l'action — un projet redéfinit ainsi une action du cœur sans la supprimer.for()les rend triées par priorité croissante (défaut : 100) ; - l'exécution reçoit les modèles résolus — une
Collectiondumodeldéclaré, jamais des identifiants bruts : les lignes disparues entre l'affichage et le clic sont écartées avant d'arriver ; - le rapport porte les deux issues :
new BulkActionReport(affected: …, errors: […]), un motif par ligne écartée (skipped()les compte,hasErrors()répond). Sans retour, le cœur considère que toutes les lignes reçues ont été traitées.
Activer et désactiver ne se réécrivent pas : BulkActivation::register($registry, 'currencies', Currency::class) pose les deux actions (activate, priorité 10 ; deactivate, 20) sur le listing d'un modèle qui utilise Slab\Framework\Models\Traits\Activable — un paquet en fait autant pour son propre référentiel. Une ligne déjà dans l'état demandé n'est pas une erreur : elle n'est simplement pas comptée, pour que le rapport dise ce qui a changé.
Rien n'est imposé
Le cœur n'ajoute d'action à aucun listing qu'il ne possède pas — il en déclare pour ses seuls référentiels activables (currencies, languages). Un listing qui n'en déclare aucune — ou dont toutes sont refusées à l'utilisateur courant — n'affiche pas de barre. À l'inverse, déclarer la première action d'un listing du cœur y ouvre la colonne de cases à cocher sans toucher au builder. Le versant listing (ids, sélection, colonnes contribuées) est détaillé dans le back-office.
Voir aussi
- Les registres — le
ViewHookRegistryen est un ; leDatatableExtensionRegistryet leBulkActionRegistryen sont les pendants pour les listings du back-office. - Créer un thème — exposer les slots front garantis.
- Surcharger une vue — quand il n'y a pas (encore) de hook.