Aller au contenu

Brouillon, publié, ordre d'affichage

Temps 1 · séance 4 · ~45 min

Les slides du chapitre (PDF)PDF

  • Masquer un projet non terminé avec la colonne published_at et un scope published().
  • Fermer l'adresse d'un brouillon avec une 404.
  • Publier et dépublier un projet depuis le back-office, par un <Form> sur une route PATCH.
  • En bonus, changer l'ordre d'affichage avec deux boutons « Monter » et « Descendre ».

1. Un interrupteur : published_at

Un projet à moitié écrit ne doit pas apparaître sur votre site. Il doit pourtant exister dans la base, pour que vous le terminiez depuis le back-office.

La colonne est prête depuis le chapitre 1.06. published_at est une date, et elle accepte null :

$table->timestamp('published_at')->nullable();

Le portfolio s'en sert comme d'un interrupteur. Une date veut dire « publié », null veut dire « brouillon ». La fabrique a déjà son état draft(), qui vide la colonne, et vos seeders publient tout avec now().

Pour avoir un brouillon à regarder, faites passer un de vos projets en brouillon dans database/seeders/ProjectSeeder.php. L'exemple du cours choisit le « Carnet de recettes » :

$recipes = Project::create([
    'title' => 'Carnet de recettes',
    // slug, résumé, contenu, image et liens : inchangés
    'published_at' => null,
    'position' => 3,
]);

Relancez php artisan migrate:fresh --seed. Le site public montre encore les trois projets : rien ne lit la colonne pour l'instant.

2. Le scope published()

La condition « publié » s'écrit whereNotNull('published_at'). Elle servira dans la liste publique, puis dans d'autres pages, comme l'accueil. Plutôt que de la recopier, donnez-lui un nom dans le modèle app/Models/Project.php :

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

/**
 * @param  Builder<Project>  $query
 */
#[Scope]
protected function published(Builder $query): void
{
    $query->whereNotNull('published_at');
}

Un scope est une condition de requête nommée, rangée dans le modèle. L'attribut #[Scope] le déclare. Il appartient à la même famille que l'attribut #[Fillable] de votre modèle. La méthode reçoit la requête en cours et lui ajoute sa condition, sans rien renvoyer.

Vous l'appelez comme une méthode statique du modèle, au début d'une requête :

php artisan tinker --execute="echo App\Models\Project::published()->pluck('title')->implode(', ');"
Météo Wavre, Quiz des capitales

Le « Carnet de recettes » n'y est plus. Project::all() les donnerait toujours tous les trois.

Note

Vous rencontrerez aussi l'ancienne écriture, public function scopePublished(Builder $query), avec le préfixe scope. Elle fonctionne toujours dans Laravel 13. L'attribut #[Scope] garde le vrai nom de la méthode.

3. La partie publique

Dans le ProjectController public, la liste commence maintenant par le scope :

$projects = Project::published()
    ->with('tags')
    ->orderBy('position')
    // get() et map() : inchangés

Ouvrez portfolio.test/projets : votre brouillon a disparu de la liste.

Ouvrez ensuite son adresse directement, /projets/ suivi de son slug. La fiche s'affiche. La liaison de route ne connaît pas le scope : {project:slug} cherche le projet par son slug dans toute la table, brouillons compris. Un visiteur qui a gardé l'adresse, ou qui la devine, lit votre brouillon.

Fermez la porte au début de show() :

public function show(Project $project): Response
{
    abort_if($project->published_at === null, 404);

    // load('tags') et Inertia::render : inchangés
}

abort_if(condition, 404) arrête la requête avec le code donné quand la condition est vraie. Le brouillon répond 404, avec votre page ErrorPage du chapitre 1.08.

4. La liste d'administration

Le back-office, lui, doit tout montrer. Sa méthode index() ne change pas : depuis le chapitre 1.11, elle lit tous les projets et envoie déjà published_at à la page.

La table projects contient les trois projets ; la colonne published_at porte une date pour Météo Wavre et Quiz des capitales, publiés, et null pour Carnet de recettes, brouillon. La partie publique lit la table avec Project::published(), soit whereNotNull('published_at'), et /projets montre deux projets ; l’administration lit tous les projets avec index(), et /admin/projets en montre trois

Ce qui manque, c'est l'état de chaque ligne. Le kit contient le composant Badge de shadcn-vue, une petite étiquette arrondie. Dans resources/js/pages/admin/projects/Index.vue, importez-le :

import { Badge } from '@/components/ui/badge';

Ajoutez une colonne « État » au tableau, avec son <TableHead> dans l'en-tête et cette cellule dans chaque ligne :

<TableCell>
    <Badge v-if="project.published_at">Publié</Badge>
    <Badge v-else variant="secondary">Brouillon</Badge>
</TableCell>

La variante par défaut est foncée, la variante secondary est grise. v-if et v-else choisissent l'une ou l'autre selon la date. Si le tableau devient trop large, retirez la colonne de l'adresse du chapitre 1.11.

5. Publier et dépublier

Le bouton « Publier » modifie une seule colonne d'un projet. Il lui faut une route, une méthode et un formulaire.

La route

Dans routes/web.php, ajoutez une route dans le groupe admin, après la ressource des projets :

Route::patch('projets/{project}/publication', [Admin\ProjectController::class, 'togglePublished'])
    ->name('projects.toggle-published');

PATCH est la méthode HTTP d'une modification partielle : une colonne, pas tout le projet. Le groupe ajoute le préfixe admin à l'adresse et admin. au nom. Vérifiez :

php artisan route:list --path=admin
 …
 PATCH admin/projets/{project}/publication admin.projects.toggle-published › Admin\ProjectController@toggl…

La méthode

Dans Admin\ProjectController :

public function togglePublished(Project $project): RedirectResponse
{
    $project->update([
        'published_at' => $project->published_at ? null : now(),
    ]);

    Inertia::flash('toast', [
        'type' => 'success',
        'message' => $project->published_at ? 'Projet publié.' : 'Projet repassé en brouillon.',
    ]);

    return to_route('admin.projects.index');
}

L'opérateur ternaire, condition ? valeur si vrai : valeur si faux, inverse l'interrupteur. Un projet publié reçoit null, un brouillon reçoit la date du moment. Le message du toast relit la colonne après la mise à jour, et dit ce qui vient de se passer.

Le formulaire

Dans Index.vue, ajoutez ce formulaire dans la cellule des actions, avant le bouton « Modifier » :

<Form
    v-bind="ProjectController.togglePublished.form(project)"
    class="inline"
>
    <Button type="submit" variant="outline" size="sm">
        {{ project.published_at ? 'Dépublier' : 'Publier' }}
    </Button>
</Form>

ProjectController est déjà importé depuis le chapitre 1.13, pour la suppression. Pendant que composer run dev tourne, Wayfinder génère togglePublished dès que la route et la méthode sont enregistrées : la fonction porte le nom de la méthode du contrôleur. Comme pour destroy.form(project), l'objet project doit contenir id, et c'est le cas.

Ce <Form> n'a aucun champ. Il ne sert qu'à envoyer une requête à la bonne adresse quand vous cliquez sur son bouton submit. Voici la requête relevée dans le navigateur au clic sur « Publier » pour le « Carnet de recettes » :

POST http://…/admin/projets/3/publication?_method=PATCH  body {}

Comme pour la modification au chapitre 1.13, Wayfinder envoie un POST avec ?_method=PATCH dans l'adresse, et Laravel appelle la route PATCH. Le corps est un objet JSON vide. La page revient avec le badge « Publié » et le toast « Projet publié. ».

Dans le navigateur, le Form lié par ProjectController.togglePublished.form(project) n’a aucun champ, seulement le bouton Publier de type submit. Au clic, il envoie POST http://…/admin/projets/3/publication?_method=PATCH avec un corps vide. Laravel lit _method, choisit la route PATCH admin/projets/{project}/publication et appelle togglePublished(), qui donne null à un projet publié et now() à un brouillon

La liste d'administration des projets : pour chaque ligne, deux flèches Monter et Descendre, le titre, un badge Publié noir ou Brouillon gris, et les boutons Dépublier ou Publier, Modifier et Supprimer

6. Bonus : l'ordre d'affichage

Ce qui suit n'est pas obligatoire. Faites-le si la partie obligatoire est finie et vérifiée.

Depuis le chapitre 1.06, la colonne position décide de l'ordre : la liste publique et la liste d'administration trient par orderBy('position'), et store() donne à chaque nouveau projet la position qui suit la plus grande. Pour changer l'ordre, il suffit d'échanger la position d'un projet avec celle de son voisin.

Deux routes et deux méthodes

Dans le groupe admin de routes/web.php :

Route::patch('projets/{project}/monter', [Admin\ProjectController::class, 'moveUp'])
    ->name('projects.move-up');
Route::patch('projets/{project}/descendre', [Admin\ProjectController::class, 'moveDown'])
    ->name('projects.move-down');

Dans Admin\ProjectController :

public function moveUp(Project $project): RedirectResponse
{
    $previous = Project::where('position', '<', $project->position)
        ->orderByDesc('position')
        ->first();

    if ($previous) {
        $this->swapPositions($project, $previous);
    }

    return to_route('admin.projects.index');
}

private function swapPositions(Project $first, Project $second): void
{
    [$first->position, $second->position] = [$second->position, $first->position];

    $first->save();
    $second->save();
}

moveUp() cherche le voisin du dessus : parmi les projets de position plus petite, celui qui a la plus grande. Le premier projet n'en a pas, et rien ne se passe. swapPositions() échange les deux valeurs en une ligne : à gauche du =, les deux propriétés reçoivent, dans l'ordre, les deux valeurs du tableau de droite.

Écrivez moveDown() seul. C'est la même méthode avec '>' et orderBy('position'), pour trouver le voisin du dessous.

Deux boutons par ligne

Dans Index.vue, importez les deux flèches de lucide, la bibliothèque d'icônes du kit :

import { ArrowDown, ArrowUp } from '@lucide/vue';

Le v-for du tableau doit aussi connaître le rang de chaque ligne. Vue le donne en second élément, entre parenthèses :

<TableRow v-for="(project, position) in projects" :key="project.id">

Ajoutez une première colonne « Ordre » avec ces deux formulaires :

<TableCell class="flex gap-1">
    <Form v-bind="ProjectController.moveUp.form(project)">
        <Button
            type="submit"
            variant="ghost"
            size="icon-sm"
            aria-label="Monter"
            :disabled="position === 0"
        >
            <ArrowUp />
        </Button>
    </Form>
    <Form v-bind="ProjectController.moveDown.form(project)">
        <Button
            type="submit"
            variant="ghost"
            size="icon-sm"
            aria-label="Descendre"
            :disabled="position === projects.length - 1"
        >
            <ArrowDown />
        </Button>
    </Form>
</TableCell>

position vaut ici 0 pour la première ligne. Le bouton « Monter » de la première ligne et le bouton « Descendre » de la dernière sont désactivés. Un bouton qui ne montre qu'une icône porte un aria-label, lu par les lecteurs d'écran.

Cliquez sur « Descendre » pour votre premier projet. La requête part comme celle de la publication :

POST http://…/admin/projets/1/descendre?_method=PATCH    body {}

La page revient avec les deux premières lignes échangées. La liste publique suit le même ordre.

Le clic sur Descendre envoie POST http://…/admin/projets/1/descendre?_method=PATCH. moveDown() trouve le voisin du dessous et swapPositions() échange les deux valeurs : Météo Wavre passe de la position 1 à 2, Quiz des capitales de 2 à 1, Carnet de recettes garde 3. Trié par orderBy('position'), /admin/projets et /projets montrent Quiz des capitales, Météo Wavre, Carnet de recettes

7. Pratique : brouillons et publication (30 min)

Faites seul ce qui est décrit plus haut, dans votre portfolio.

Obligatoire :

  1. Un de vos projets en brouillon dans le seeder, puis php artisan migrate:fresh --seed.
  2. Le scope published() dans Project, essayé dans Tinker.
  3. La liste publique qui n'affiche que les projets publiés.
  4. abort_if dans show(), pour que l'adresse d'un brouillon réponde 404.
  5. La colonne « État » avec un Badge dans la liste d'administration.
  6. La route PATCH admin.projects.toggle-published, la méthode togglePublished() et son <Form>.

Vérifiez dans le navigateur.

  • portfolio.test/projets ne montre pas votre brouillon.
  • L'adresse de votre brouillon répond par votre page 404.
  • /admin/projets montre tous vos projets, avec un badge « Publié » ou « Brouillon ».
  • « Publier » fait apparaître le projet sur le site, avec le toast « Projet publié. », et « Dépublier » le retire.

Bonus :

  1. Les routes monter et descendre, moveUp(), moveDown() et swapPositions().
  2. La colonne « Ordre » avec ses deux boutons, désactivés aux extrémités.
  • « Descendre » sur la première ligne l'échange avec la deuxième, dans le back-office et sur le site public.

Ce que votre portfolio contient maintenant

Des projets publiés et des brouillons. Le site public ne montre que les premiers, et l'adresse d'un brouillon répond 404. Le back-office montre l'état de chaque projet et le publie ou le dépublie d'un clic. En bonus, deux flèches par ligne règlent l'ordre d'affichage.

À retenir

  • published_at sert d'interrupteur : une date pour publié, null pour brouillon.
  • Un scope #[Scope] donne un nom à une condition de requête, et Project::published() l'applique.
  • La liaison de route ignore le scope, donc show() refuse un brouillon avec abort_if(..., 404).
  • Un bouton qui modifie un projet est un <Form> sans champ, lié par Wayfinder à une route PATCH, avec un bouton type="submit".
  • Échanger la position de deux voisins suffit à changer l'ordre, sans glisser-déposer.