Notre CMS dispose de menus dynamiques équipés de sous-menus. Nous avons déjà mis en place la partie données dès le début de cette série. On a aussi vu comment placer les menus sur les pages pour les visiteurs. Maintenant, nous allons coder la partie administration pour pouvoir à loisir créer des menus, en supprimer, en modifier, de même pour les sous-menus. Nous aurons quelques points délicats à traiter.
Je rappelle que les menus ont une simple hiérarchie :
- le premier niveau avec un item qui renvoient directement à un élément du CMS (page, article, catégorie) ou qui constitue la base pour des sous-menus
- le second niveau avec un item qui renvoie systématiquement à un élément du CMS
Pour rappel, la table des matières est ici.
Le code commun
Un trait
Comme on va avoir du code commun à plusieurs composants, on va commencer par créer un trait pour la partie PHP :
Voilà pour le code :
<?php
namespace App\Traits;
use Illuminate\Support\Collection;
use App\Models\{Category, Page, Post};
trait ManageMenus
{
public ?int $post_id = null;
public Collection $postsSearchable;
public function search(string $value = ''): void
{
$selectedOption = Post::select('id', 'title')->where('id', $this->post_id)->get();
$this->postsSearchable = Post::query()
->select('id', 'title')
->where('title', 'like', "%{$value}%")
->orderBy('title')
->take(5)
->get()
->merge($selectedOption);
}
public function changeSelection($value): void
{
$this->updateSubProperties(['model' => Post::class, 'route' => 'posts.show'], $value);
}
public function updating($property, $value): void
{
if ($value === '') {
return;
}
$modelMap = [
'subPage' => ['model' => Page::class, 'route' => 'pages.show'],
'subCategory' => ['model' => Category::class, 'route' => 'category'],
];
if (array_key_exists($property, $modelMap)) {
$this->updateSubProperties($modelMap[$property], $value);
} elseif ($property === 'subOption') {
$this->resetSubProperties();
$this->search();
}
}
private function updateSubProperties($modelInfo, $value): void
{
$model = $modelInfo['model']::find($value);
if ($model) {
$this->sublabel = $model->title;
$this->sublink = $modelInfo['route'] === 'posts.show' || $modelInfo['route'] === 'pages.show'
? route($modelInfo['route'], $model->slug)
: url($modelInfo['route'] . '/' . $model->slug);
}
}
private function resetSubProperties(): void
{
$this->sublabel = '';
$this->sublink = '';
$this->subPost = 0;
$this->subPage = 0;
$this->subCategory = 0;
}
public function with(): array
{
return [
'pages' => Page::select('id', 'title', 'slug')->get(),
'categories' => Category::all(),
'subOptions' => [['id' => 1, 'name' => __('Post')], ['id' => 2, 'name' => __('Page')], ['id' => 3, 'name' => __('Category')]],
];
}
}
Ce trait regroupe quelques fonctionnalités :
- transmettre au formulaire de création ou de modification d'un sous-menu les informations concernant les pages et les catégories disponibles
- transmettre au formulaire les informations des articles disponibles. Mais comme on peut avoir de très nombreux articles, contrairement aux pages et catégories qui sont logiquement en nombre limité et peuvent entrer dans une liste, on va prévoir de limiter le nombre d'articles visibles dans la liste, mais permettre une recherche
- une grande partie du code concerne la gestion des sélections des catégories, pages et articles dans les formulaires
Un formulaire pour les sous-menus
Le formulaire pour les sous-menus va concerner à la fois la création et la modification, alors on prévoit un composant dédié :
Avec ce code :
<x-form wire:submit="saveSubmenu({{ $menu->id ?? 'null' }})">
<x-radio :options="$subOptions" wire:model="subOption" wire:change="$refresh" />
@if ($subOption == 1)
<x-choices label="{{ __('Post') }}" wire:model="subPost" :options="$postsSearchable" option-label="title"
hint="{{ __('Select a post, type to search') }}" debounce="300ms" min-chars="2"
no-result-text="{{ __('No result found!') }}" single searchable @change-selection="$wire.changeSelection($event.detail.value)" />
@elseif($subOption == 2)
<x-select label="{{ __('Page') }}" option-label="title" :options="$pages"
placeholder="{{ __('Select a page') }}" wire:model="subPage"
wire:change="$refresh" />
@elseif($subOption == 3)
<x-select label="{{ __('Category') }}" option-label="title" :options="$categories"
placeholder="{{ __('Select a category') }}" wire:model="subCategory"
wire:change="$refresh" />
@endif
<x-input label="{{ __('Title') }}" wire:model="sublabel" />
<x-input type="text" wire:model="sublink" label="{{ __('Link') }}" />
<x-slot:actions>
<x-button label="{{ __('Save') }}" icon="o-paper-airplane" spinner="save"
type="submit" class="btn-primary" />
</x-slot:actions>
</x-form>
On va voir plus loin son utilisation.
Un composant pour les menus
On a à nouveau besoin d'un composant Volt pour gérer la liste des menus et le code PHP qui va faire tout le traitement :
php artisan make:volt admin/menus/index --class
On va ajouter la route pour l'atteindre (réservée aux administrateurs) :
Route::middleware('auth')->group(function () {
...
Route::middleware(IsAdminOrRedac::class)->prefix('admin')->group(function () {
...
Route::middleware(IsAdmin::class)->group(function () {
...
Volt::route('/menus/index', 'admin.menus.index')->name('menus.index');
On ajoute un item dans la barre latérale (admin.sidebar) :
@if (Auth::user()->isAdmin())
<x-menu-sub title="{{ __('Menus') }}" icon="m-list-bullet">
<x-menu-item title="{{ __('Navbar') }}" link="{{ route('menus.index') }}" />
</x-menu-sub>
@endif
Une traduction :
"Navbar": "Navigation",
Vous vous demandez peut-être pourquoi un sous-menu ? C'est parce que par la suite, on va devoir aussi gérer le menu de bas de page qui est distinct et qui va aussi bénéficier d'un sous-menu.
On peut désormais atteindre le nouveau composant, il ne nous reste plus qu'à le compléter.
La liste des menus
Voilà le code complet du composant menus.index, je vais commenter plus loin les points essentiels :
<?php
use App\Models\{Menu, Submenu};
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Livewire\Attributes\{Layout, Validate, Title};
use Livewire\Volt\Component;
use Mary\Traits\Toast;
use App\Traits\ManageMenus;
new #[Title('Nav Menu'), Layout('components.layouts.admin')]
class extends Component {
use Toast, ManageMenus;
public Collection $menus;
#[Validate('required|max:255|unique:menus,label')]
public string $label = '';
#[Validate('nullable|regex:/\/([a-z0-9_-]\/*)*[a-z0-9_-]*/')]
public string $link = '';
public string $sublabel = '';
public string $sublink = '';
public int $subPost = 0;
public int $subPage = 0;
public int $subCategory = 0;
public int $subOption = 1;
// Méthode appelée lors de l'initialisation du composant.
public function mount(): void
{
$this->getMenus();
$this->search();
}
// Récupérer les menus avec leurs sous-menus triés par ordre.
public function getMenus(): void
{
$this->menus = Menu::with([
'submenus' => function (Builder $query) {
$query->orderBy('order');
},
])
->orderBy('order')
->get();
}
// Méthode générique pour déplacer un élément (menu ou sous-menu)
private function move($item, $direction, $isSubmenu = false): void
{
$operator = $direction === 'up' ? '<' : '>';
$orderDirection = $direction === 'up' ? 'desc' : 'asc';
$query = $isSubmenu
? Submenu::where('menu_id', $item->menu_id)
: Menu::query();
$adjacentItem = $query->where('order', $operator, $item->order)
->orderBy('order', $orderDirection)
->first();
if ($adjacentItem) {
$this->swap($item, $adjacentItem);
}
}
public function up(Menu $menu): void
{
$this->move($menu, 'up');
}
public function upSub(Submenu $submenu): void
{
$this->move($submenu, 'up', true);
}
public function down(Menu $menu): void
{
$this->move($menu, 'down');
}
public function downSub(Submenu $submenu): void
{
$this->move($submenu, 'down', true);
}
private function swap($item1, $item2): void
{
$tempOrder = $item1->order;
$item1->order = $item2->order;
$item2->order = $tempOrder;
$item1->save();
$item2->save();
$this->getMenus();
}
// Méthode générique pour supprimer un élément (menu ou sous-menu)
private function deleteItem($item, $parent = null): void
{
$isSubmenu = $parent !== null;
$item->delete();
if ($isSubmenu) {
$this->reorderItems($parent->submenus());
} else {
$this->reorderItems(Menu::query());
}
$this->getMenus();
$this->success(__($isSubmenu ? 'Submenu' : 'Menu') . __(' deleted with success.'));
}
public function deleteMenu(Menu $menu): void
{
$this->deleteItem($menu);
}
public function deleteSubmenu(Menu $menu, Submenu $submenu): void
{
$this->deleteItem($submenu, $menu);
}
// Méthode générique pour réordonner les éléments
private function reorderItems($query): void
{
$items = $query->orderBy('order')->get();
foreach ($items as $index => $item) {
$item->order = $index + 1;
$item->save();
}
}
// Enregistrer un nouveau menu.
public function saveMenu(): void
{
$data = $this->validate();
$data['order'] = $this->menus->count() + 1;
Menu::create($data);
$this->success(__('Menu created with success.'), redirectTo: '/admin/menus/index');
}
// Enregistrer un nouveau sous-menu.
public function saveSubmenu(Menu $menu): void
{
$data = $this->validate([
'sublabel' => ['required', 'string', 'max:255'],
'sublink' => 'required|regex:/\/([a-z0-9_-]\/*)*[a-z0-9_-]*/',
]);
$data['order'] = $menu->submenus->count() + 1;
$data['label'] = $this->sublabel;
$data['link'] = $this->sublink;
$menu->submenus()->save(new Submenu($data));
$this->sublabel = '';
$this->sublink = '';
$this->success(__('Submenu created with success.'));
}
}; ?>
<div>
<x-header title="{{ __('Navigation') }}" separator progress-indicator>
<x-slot:actions class="lg:hidden">
<x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline"
link="{{ route('admin') }}" />
</x-slot:actions>
</x-header>
<x-card>
@foreach ($menus as $menu)
<x-list-item :item="$menu" no-separator no-hover>
<x-slot:value>
{{ $menu->label }}
</x-slot:value>
<x-slot:sub-value>
@if ($menu->link)
{{ $menu->link }}
@else
@lang('Root menu')
@endif
</x-slot:sub-value>
<x-slot:actions>
@if ($menu->order > 1)
<x-popover>
<x-slot:trigger>
<x-button icon="s-chevron-up" wire:click="up({{ $menu->id }})" spinner />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Up')
</x-slot:content>
</x-popover>
@endif
@if ($menu->order < $menus->count())
<x-popover>
<x-slot:trigger>
<x-button icon="s-chevron-down" wire:click="down({{ $menu->id }})" spinner />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Down')
</x-slot:content>
</x-popover>
@endif
<x-popover>
<x-slot:trigger>
<x-button icon="c-arrow-path-rounded-square" link="#"
class="text-blue-500 btn-ghost btn-sm" spinner />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Edit')
</x-slot:content>
</x-popover>
<x-popover>
<x-slot:trigger>
<x-button icon="o-trash" wire:click="deleteMenu({{ $menu->id }})"
wire:confirm="{{ __('Are you sure to delete this menu?') }}" spinner
class="text-red-500 btn-ghost btn-sm" />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Delete')
</x-slot:content>
</x-popover>
</x-slot:actions>
</x-list-item>
<x-collapse collapse-plus-minus no-icon class="ml-8">
<x-slot:heading>
<x-icon name="o-chevron-down" /><span class="pl-2 text-sm">{{ __('Submenus') }}</span>
</x-slot:heading>
<x-slot:content>
@foreach ($menu->submenus as $submenu)
<x-list-item :item="$menu" no-separator no-hover>
<x-slot:value>
{{ $submenu->label }}
</x-slot:value>
<x-slot:sub-value>
{{ $submenu->link }}
</x-slot:sub-value>
<x-slot:actions>
@if ($submenu->order > 1)
<x-popover>
<x-slot:trigger>
<x-button icon="s-chevron-up" wire:click="upSub({{ $submenu->id }})" spinner />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Up')
</x-slot:content>
</x-popover>
@endif
@if ($submenu->order < $menu->submenus->count())
<x-popover>
<x-slot:trigger>
<x-button icon="s-chevron-down" wire:click="downSub({{ $submenu->id }})" spinner />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Down')
</x-slot:content>
</x-popover>
@endif
<x-popover>
<x-slot:trigger>
<x-button icon="c-arrow-path-rounded-square" link="#"
class="text-blue-500 btn-ghost btn-sm" spinner />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Edit')
</x-slot:content>
</x-popover>
<x-popover>
<x-slot:trigger>
<x-button icon="o-trash" wire:click="deleteSubmenu({{ $menu->id }}, {{ $submenu->id }})"
wire:confirm="{{ __('Are you sure to delete this menu?') }}" spinner
class="text-red-500 btn-ghost btn-sm" />
</x-slot:trigger>
<x-slot:content class="pop-small">
@lang('Delete')
</x-slot:content>
</x-popover>
</x-slot:actions>
</x-list-item>
@endforeach
<br>
<x-card class="" title="{{ __('Create a new submenu') }}">
@include('livewire.admin.menus.submenu-form')
</x-card>
</x-slot:content>
</x-collapse>
@endforeach
</x-card>
<br>
<x-card class="" title="{{ __('Create a new menu') }}">
<x-form wire:submit="saveMenu">
<x-input label="{{ __('Title') }}" wire:model="label" />
<x-input type="text" wire:model="link" label="{{ __('Link') }}" />
<x-slot:actions>
<x-button label="{{ __('Save') }}" icon="o-paper-airplane" spinner="save" type="submit"
class="btn-primary" />
</x-slot:actions>
</x-form>
</x-card>
</div>
On a encore besoin de traductions :
"Submenus": "Sous-menus",
"Edit a submenu": "Modifier un sous-menu",
"Create a new menu": "Créer un nouveau menu",
"Are you sure to delete this menu?": "Êtes-vous sûr de vouloir supprimer ce menu ?",
"Menu created with success.": "Menu ajouté avec succès.",
"Menu updated with success.": "Menu mis à jour avec succès.",
"Edit a menu": "Modifier un menu",
"Create a new submenu": "Créer un nouveau sous-menu",
"Are you sure to delete this submenu?": "Êtes-vous sûr de vouloir supprimer ce sous-menu ?",
" deleted with success.": " supprimé avec succès.",
"Submenu": "Sous-menu",
"Edit": "Modifier",
"Link": "Lien",
"Select a post, type to search": "Sélectionnez un article, tapez pour rechercher",
"Edit" est déjà présent, mais on change la traduction pour la rendre plus précise.
La liste des menus de premier niveau
Les menus de premier niveau apparaissent dans l'ordre (donc tels qu'ils seront rendus sur le frontend). Il est possible de changer cet ordre à l'aide des flèches vers le haut et vers le bas :
Le bouton situé à côté des flèches sert à ouvrir le formulaire de modification (pas encore codé pour le moment). La petite poubelle sert évidemment à supprimer le menu concerné.
Sous la liste, on trouve le formulaire pour créer un nouveau menu de premier niveau :
Les sous-menus
Pour chaque menu de premier niveau, on peut dérouler ses sous-menus (s'il en comporte) :
On trouve la même logique pour le changement d'ordre, la modification et la suppression.
Et au-dessous le formulaire pour créer un nouveau sous-menu :
Pour les articles, on a la liste des cinq derniers, mais on peut aussi faire une recherche textuelle. Pour les catégories et les pages, on a la totalité directement disponible.
Vérifier que tout ça fonctionne bien en créant des menus, des sous-menus, en changeant l'ordre...
L'ordre des menus pour un déplacement
Une partie un peu délicate du codage concerne l'ordre des menus. Dans la base, on a une colonne order qui nous indique cet ordre :
Lorsqu'on veut modifier cet ordre, on doit changer ces valeurs. D'autre part, si on supprime un menu, on doit décaler les valeurs pour éviter d'avoir un trou. Ce sont ces deux fonctions qui assurent la gestion des changements dans l'ordre (menu déplacé vers le haut ou vers le bas) :
private function move($item, $direction, $isSubmenu = false): void
{
$operator = $direction === 'up' ? '<' : '>';
$orderDirection = $direction === 'up' ? 'desc' : 'asc';
$query = $isSubmenu
? Submenu::where('menu_id', $item->menu_id)
: Menu::query();
$adjacentItem = $query->where('order', $operator, $item->order)
->orderBy('order', $orderDirection)
->first();
if ($adjacentItem) {
$this->swap($item, $adjacentItem);
}
}
private function swap($item1, $item2): void
{
$tempOrder = $item1->order;
$item1->order = $item2->order;
$item2->order = $tempOrder;
$item1->save();
$item2->save();
$this->getMenus();
}
La fonction "move"
Cette fonction est conçue pour déplacer un élément (menu ou sous-menu) vers le haut ou vers le bas dans une liste ordonnée.
private function move($item, $direction, $isSubmenu = false): void
- $item : L'élément à déplacer
- $direction : La direction du déplacement ('up' ou 'down')
- $isSubmenu : Un booléen indiquant si l'élément est un sous-menu (par défaut, c'est false)
Étapes de la fonction
Définir l'opérateur de comparaison (si la direction est 'up', l'opérateur sera '<', sinon '>') :
$operator = $direction === 'up' ? '<' : '>';
Définir la direction de tri (si la direction est 'up', le tri sera descendant, sinon ascendant) :
$orderDirection = $direction === 'up' ? 'desc' : 'asc';
Préparer la requête (si c'est un sous-menu, on filtre par menu_id, sinon on prend tous les menus) :
$query = $isSubmenu
? Submenu::where('menu_id', $item->menu_id)
: Menu::query();
Trouver l'élément adjacent (on cherche l'élément dont l'ordre est juste avant ou après l'élément actuel) :
$adjacentItem = $query->where('order', $operator, $item->order)
->orderBy('order', $orderDirection)
->first();
Échanger les positions si un élément adjacent est trouvé :
if ($adjacentItem) {
$this->swap($item, $adjacentItem);
}
Fonction "swap"
Cette fonction échange l'ordre de deux éléments.
private function swap($item1, $item2): void
Étapes de la fonction
Échanger les valeurs d'ordre :
$tempOrder = $item1->order;
$item1->order = $item2->order;
$item2->order = $tempOrder;
Sauvegarder les changements dans la base de données :
$item1->save();
$item2->save();
Rafraîchir la liste des menus :
$this->getMenus();
Ce code permet de réorganiser dynamiquement l'ordre des menus ou sous-menus en échangeant leurs positions lorsqu'on veut les déplacer vers le haut ou vers le bas.
L'ordre des menus pour une suppression
Lorsqu'on supprime un menu, il faut aussi réorganiser l'ordre pour éviter d'avoir un trou. Ce sont ces deux fonctions qui assurent cette gestion :
private function deleteItem($item, $parent = null): void
{
$isSubmenu = $parent !== null;
$item->delete();
if ($isSubmenu) {
$this->reorderItems($parent->submenus());
} else {
$this->reorderItems(Menu::query());
}
$this->getMenus();
$this->success(__($isSubmenu ? 'Submenu' : 'Menu') . __(' deleted with success.'));
}
// Méthode générique pour réordonner les éléments
private function reorderItems($query): void
{
$items = $query->orderBy('order')->get();
foreach ($items as $index => $item) {
$item->order = $index + 1;
$item->save();
}
}
Ce code gère la suppression d'un menu ou sous-menu et s'assure que les éléments restants sont correctement réordonnés après la suppression. Il utilise une approche générique pour la réorganisation, ce qui permet de l'utiliser à la fois pour les menus et les sous-menus.
Fonction "deleteItem"
Cette fonction est conçue pour supprimer un élément (menu ou sous-menu) et réorganiser les éléments restants.
private function deleteItem($item, $parent = null): void
- $item : L'élément à supprimer
- $parent : Le parent de l'élément (null si c'est un menu principal)
Étapes de la fonction
Déterminer si c'est un sous-menu (si $parent n'est pas null, c'est un sous-menu.) :
$isSubmenu = $parent !== null;
Supprimer l'élément :
$item->delete();
Réordonner les éléments restants :
if ($isSubmenu) {
$this->reorderItems($parent->submenus());
} else {
$this->reorderItems(Menu::query());
}
Si c'est un sous-menu, on réordonne les sous-menus du parent. Sinon, on réordonne tous les menus principaux.
Rafraîchir la liste des menus :
$this->getMenus();
Afficher un message de succès :
$this->success(__($isSubmenu ? 'Submenu' : 'Menu') . __(' deleted with success.'));
Fonction "reorderItems"
Cette fonction réordonne une collection d'éléments (menus ou sous-menus).
private function reorderItems($query): void
$query : Une requête Eloquent pour récupérer les éléments à réordonner
Étapes de la fonction
Récupérer les éléments triés (récupère tous les éléments triés par leur ordre actuel) :
$items = $query->orderBy('order')->get();
Réordonner les éléments :
foreach ($items as $index => $item) {
$item->order = $index + 1;
$item->save();
}
Pour chaque élément, on met à jour son ordre en fonction de sa position dans la liste (en commençant par 1).
Conclusion
Nous pouvons à présent créer des menus et des sous-menus, les supprimer, les réorganiser. Dans la prochaine étape, nous allons aussi prévoir la modification d'un menu ou sous-menu existant. Il nous faudra ensuite également gérer les menus de bas de page, mais nous bénéficierons de tout ce que nous avons déjà mis en place précédemment.
Pour vous simplifier la vie, vous pouvez charger le projet dans son état à l’issue de ce chapitre.
Par bestmomo
Aucun commentaire