Aller au contenu

Le refuge : exercices

Bloc Laravel · séance 9 · ~3 h

Trois refuges du Brabant wallon, à Wavre, Rixensart et Ottignies, publient sur un même site les animaux qu'ils proposent à l'adoption. Les bénévoles y ajoutent un animal, corrigent sa fiche, cochent ses traits de caractère, le marquent adopté et suppriment une fiche créée par erreur. Le site n'a pas de compte : l'authentification arrive au cours suivant. Dans ce chapitre, vous écrivez ce site en huit katas, avec ce que la friterie vous a appris aux chapitres 15 et 16.

À la fin de ce chapitre, vous serez capables de :

  1. écrire les routes, les contrôleurs et les vues d'un site, à partir d'un tableau de routes imposées,
  2. lire un test rouge, et trouver ce qui manque dans votre code,
  3. valider un formulaire, et afficher ses erreurs en français sous chaque champ,
  4. enregistrer des cases à cocher, une adoption et une suppression depuis des formulaires,
  5. vérifier la mise en forme de chaque page avec une liste de critères.

1. Le refuge

Installer le dépôt (10 minutes, une seule fois)

Sur github.com/opmvpc/refuge-26, créez votre copie avec le bouton Fork. Ouvrez ensuite un terminal dans le dossier de Herd ou de Laragon, et préparez le projet :

git clone https://github.com/VOTRE-COMPTE/refuge-26.git
cd refuge-26
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
npm install
composer run dev

Ce sont les commandes de la friterie, au chapitre 15. Si migrate propose de créer database/database.sqlite, appuyez sur Entrée pour répondre yes. La base reçoit trois refuges, huit traits de caractère et douze animaux, dont deux adoptés, avec des dates fixes : vos pages ressemblent aux captures. Laissez composer run dev tourner. Avec Laragon, rechargez d'abord Laragon depuis son menu, puis ouvrez http://refuge-26.test. Au départ, seule la page /composants s'affiche, car les autres routes sont à écrire.

Ce que le dépôt contient

Déjà dans le dépôt Ce que vous écrivez
les migrations des tables shelters, tags, animals et animal_tag les routes de routes/web.php, sauf la redirection de / vers /animaux et la route styleguide
l'enum App\Enums\Species et les modèles Shelter, Animal et Tag, avec leurs relations, #[Fillable] et casts() AnimalController et ShelterController
les fabriques, et les seeders des trois refuges, des huit traits et des douze animaux les vues animals/index, show, create, edit et shelters/show
le layout vert mousse <x-layouts.app>, les composants <x-button>, <x-badge>, <x-card> et <x-nav-link>, la page /composants les règles de validation, dans validate() ou dans une Form Request AnimalRequest
les composants de champ de la friterie : <x-label>, <x-input>, <x-select> et <x-textarea> les traductions françaises, avec le paquet Laravel-Lang
les pictogrammes public/images/species/chien.svg, chat.svg et lapin.svg l'affichage du message flash dans le layout
les tests des huit katas dans tests/Feature/, et la CI dans .github/workflows/tests.yml

La page /composants montre chaque composant dans ses variantes, avec ses props. Gardez-la ouverte : <x-button> y accepte une prop href, qui en fait un lien en forme de bouton, et la prop variant de <x-badge> vaut adopted, species ou tag.

Le schéma

class Shelter {
    +name: string
    +city: string
    +animals: Animal [0..*]
}

class Animal {
    +name: string
    +species: Species
    +birth_date: date [0..1]
    +description: string [0..1]
    +adopted_at: date [0..1]
    +shelter: Shelter [1]
    +tags: Tag [0..*]
    +age(): int [0..1]
    +isAdopted(): bool
}

class Tag {
    +name: string
    +animals: Animal [0..*]
}

enum Species <<enumeration>> {
    Dog
    Cat
    Rabbit
}

Shelter "1 +shelter" *-- "0..* +animals" Animal : héberge >
Animal "0..* +animals" -- "0..* +tags" Tag : présente >

Le losange plein {uml:composition} correspond à la clé étrangère shelter_id, qui supprime en cascade les animaux d'un refuge. La table pivot animal_tag relie les animaux et leurs traits, comme allergen_product à la friterie. La base enregistre l'espèce en texte, chien, chat ou lapin. $animal->species->label() affiche « Chien », et $animal->species->image() donne le chemin du pictogramme.

Le modèle des traits de caractère s'appelle Tag, et non Trait. trait est le mot-clé des traits du chapitre 7 de la POO, et class Trait {} s'arrête sur Parse error: syntax error, unexpected token "trait", expecting identifier. Dans les textes affichés, écrivez « trait de caractère ».

Le menu écrit en dur

Le layout écrit les adresses du menu à la main, sans route() :

<li><x-nav-link href="/animaux" :active="request()->is('animaux')">Les animaux</x-nav-link></li>
<li><x-nav-link href="/animaux/nouveau" :active="request()->is('animaux/nouveau')">Ajouter un animal</x-nav-link></li>

Tant que la route animals.index n'existe pas, route('animals.index') lève une exception. Écrite dans le layout, elle ferait planter toutes les pages, /composants compris. request()->is('animaux') compare l'adresse de la page ouverte, là où request()->routeIs() du chapitre 13, section 6, compare le nom de la route.

2. Les routes imposées

Les tests envoient des requêtes sur ces adresses, avec ces verbes, et vérifient ces noms. Dans routes/web.php, le paramètre s'écrit {animal} avec la liaison de modèle du chapitre 15, section 6, ou {id} avec findOrFail(), comme au chapitre 10. Déclarez /animaux/nouveau avant /animaux/{animal}, ou posez whereNumber sur le paramètre, pour éviter le piège de l'ordre du chapitre 7.

Ce tableau décrit le site terminé. Le kata 17.2 crée un animal sans validation, le kata 17.3 ajoute la validation, et le kata 17.5 les cases des traits. Lisez ce tableau avant le kata 17.1. Les deux suivants servent aux katas 17.3 et 17.5, qui y renvoient. Le dernier se relit à chaque kata.

Verbe Adresse Nom Méthode Effet attendu
GET /animaux animals.index AnimalController@index les animaux non adoptés, triés par nom ; bonus : ?espece=chat filtre, valeur inconnue ignorée
GET /animaux/nouveau animals.create create le formulaire d'ajout, avec le <select> des refuges et les cases des traits
POST /animaux animals.store store valide, crée, sync les traits, redirige vers la fiche, flash status = « L'animal a été ajouté. »
GET /animaux/{id} animals.show show nom, espèce (libellé), refuge, âge, traits, badge « Adopté » le cas échéant, boutons Modifier, Adopter (si non adopté), Supprimer ; 404 sinon
GET /animaux/{id}/modifier animals.edit edit le formulaire pré-rempli, traits pré-cochés, @method('PUT')
PUT /animaux/{id} animals.update update valide, met à jour, sync, redirige vers la fiche, flash « L'animal a été modifié. »
PATCH /animaux/{id}/adoption animals.adopt adopt pose adopted_at à aujourd'hui si nul (ne change rien sinon), redirige vers la fiche, flash « {nom} a trouvé une famille. »
DELETE /animaux/{id} animals.destroy destroy supprime, redirige vers la liste, flash « L'animal a été supprimé. »
GET /refuges/{id} shelters.show ShelterController@show bonus : le refuge, sa ville, ses animaux à adopter

Les champs portent les mêmes noms à l'ajout et à la modification :

Champ Élément Règles
name <input type="text"> required, string, max:50
species <select> sur Species::cases(), value="{{ $species->value }}" required, Rule::enum(Species::class)
shelter_id <select> sur les refuges required, integer, exists:shelters,id
birth_date <input type="date"> nullable, date, before_or_equal:today
description <textarea> nullable, string, max:1000
tags cases name="tags[]" value="{{ $tag->id }}" nullable, array ; tags.* : integer, exists:tags,id

Dans les messages d'erreur, chaque champ porte un nom français :

Champ Nom dans les messages
name nom
species espèce
shelter_id refuge
birth_date date de naissance
description description
tags traits de caractère
tags.* trait de caractère

Les tests cherchent ces textes dans les pages ou dans la session :

Texte Quand
« L'animal a été ajouté. » message flash, après l'ajout
« L'animal a été modifié. » message flash, après la modification
« Rex a trouvé une famille. », avec le nom de l'animal adopté message flash, après l'adoption
« L'animal a été supprimé. » message flash, après la suppression
« Adopté » badge, sur la fiche d'un animal adopté
« Aucun animal » liste vide
« Chien », « Chat », « Lapin » libellé de l'espèce, sur la fiche

Dans le HTML des formulaires, les tests cherchent aussi method="POST" et l'attribut action écrit avec route(), entre guillemets doubles, comme dans les tableaux des katas. Le reste du HTML, les classes Tailwind, l'ordre des champs et les autres textes sont libres. Les critères de chaque kata décrivent la mise en forme attendue.

3. Afficher et ajouter

Les katas 17.1 à 17.3 affichent les animaux, puis ajoutent un animal avec un formulaire validé. Ils reprennent le chapitre 15 : gardez-le ouvert à côté.

Kata Groupe À relire
17.1 La liste et la fiche (20 minutes) liste ProductController de la friterie (chapitre 15, section 1), la liaison de modèle (chapitre 15, section 6), la carte et sa grille (exercice 14.3)
17.2 Ajouter un animal (25 minutes) ajouter chapitre 15, sections 2 et 5
17.3 Valider le formulaire (25 minutes, installation comprise) valider chapitre 15, sections 3 et 4

Lire un kata

Un kata est un groupe de tests Pest, dans un fichier de tests/Feature/. Les tests sont l'énoncé : chacun porte une phrase qui dit ce qu'il attend, et le tableau de chaque kata reprend ces phrases mot pour mot. Lancez le groupe avant d'écrire la moindre ligne :

php artisan test --group=liste
   FAIL  Tests\Feature\ListeTest
  ⨯ la route animals.index répond à GET /animaux                                         0.77s
  ⨯ la liste affiche les animaux à adopter, triés par nom                                0.21s
  ⨯ la liste ne montre pas les animaux adoptés                                           0.04s
  ⨯ une liste vide affiche « Aucun animal »                                              0.03s
  ⨯ la fiche affiche le nom, l'espèce, le refuge et les traits de caractère              0.04s
  ⨯ la fiche d'un animal adopté affiche « Adopté »                                       0.03s
  ✓ la fiche d'un animal inconnu répond 404                                              0.03s
…

Sous la liste, Pest détaille chaque échec : la phrase, la raison, puis la ligne du test. Quatre raisons reviennent souvent :

   FAILED  Tests\Feature\ListeTest > la route animals.index répond à GET /animaux
  La route animals.index n'existe pas.
Failed asserting that false is true.

La route manque, ou elle porte un autre nom. La première ligne vient du test, la seconde de Pest.

   FAILED  Tests\Feature\ListeTest > la liste affiche les animaux à adopter, triés par nom
  Expected response status code [200] but received 404.
Failed asserting that 404 is identical to 200.

Aucune route ne répond à GET /animaux.

   FAILED  Tests\Feature\ModifierTest > envoyer le formulaire de mod…  RouteNotFoundException
  Route [animals.show] not defined.

Le test appelle route('animals.show') pour construire l'adresse de la redirection attendue. La route d'un kata précédent manque : écrivez d'abord celle du kata 17.1.

   FAILED  Tests\Feature\ValiderTest > les traductions françaises sont installées
  Lancez composer require laravel-lang/common --dev, puis php artisan lang:add fr, et faites un commit de lang/.
Failed asserting that false is true.

Le message dit quoi faire. Pour le reste, ouvrez le fichier du test : il montre les données créées, l'adresse demandée et le texte cherché. Lancé depuis un assistant IA, php artisan test peut répondre en JSON, avec les mêmes phrases et les mêmes messages. Pour relancer un seul test, filtrez sur un morceau de sa phrase :

php artisan test --filter="triés par nom"

Six tests sur 41 sont verts dès le départ : les cinq « … répond 404 », et « adopter un animal déjà adopté ne change pas sa date d'adoption ». Ils doivent le rester quand vous écrivez les routes. Avant chaque commit, lancez php artisan test sans groupe : les katas 17.4 et 17.5 modifient store(), et peuvent faire repasser au rouge les groupes ajouter et valider. Un groupe vert valide ce que ses tests contrôlent. Les couleurs, les champs pré-remplis à l'écran et les boîtes de confirmation se vérifient dans le navigateur, avec les critères de chaque kata. Une largeur, le clavier et un attribut se vérifient comme au chapitre 14, section 1 : F12 puis Ctrl+Maj+M, la touche Tab, l'onglet Éléments.

Dès qu'un groupe est vert, faites un commit et poussez. La première fois, l'onglet Actions de votre fork demande d'activer les workflows, comme au chapitre 2 de la POO. Il affiche ensuite une coche par kata.

17.1 La liste et la fiche (20 minutes)

Créez AnimalController avec index() et show(), et les routes animals.index et animals.show. La liste ne montre que les animaux à adopter, triés par nom, dans une grille de cartes <x-card>. Chaque carte montre le pictogramme de l'espèce dans le slot image, avec asset($animal->species->image()), comme la couverture des jeux à l'exercice 14.3. Viennent ensuite le nom, en lien vers la fiche, puis le refuge. La fiche affiche le nom, l'espèce, le refuge, l'âge, les traits et la description. $animal->age() renvoie null quand la date de naissance est inconnue. Relisez ProductController de la friterie, et le chapitre 15, section 6.

Écrivez le nom du refuge sans lien pour l'instant. route('shelters.show', …) ferait planter la page tant que la route du bonus 17.8 n'existe pas, comme le menu écrit en dur. Les captures viennent de la solution, qui contient ce bonus.

php artisan make:controller AnimalController
php artisan test --group=liste
Test Ce que vous écrivez
la route animals.index répond à GET /animaux Route::get('/animaux', …)->name('animals.index')
la liste affiche les animaux à adopter, triés par nom orderBy('name') dans index()
la liste ne montre pas les animaux adoptés whereNull('adopted_at'), qui ne garde que les lignes où la colonne est vide
une liste vide affiche « Aucun animal » @forelse avec @empty, ou @if ($animals->isEmpty())
la fiche affiche le nom, l'espèce, le refuge et les traits de caractère $animal->species->label(), $animal->shelter->name et un @foreach sur $animal->tags
la fiche d'un animal adopté affiche « Adopté » @if ($animal->isAdopted()) autour de <x-badge variant="adopted">Adopté</x-badge>
la fiche d'un animal inconnu répond 404 findOrFail(), ou la liaison de modèle

La page /animaux de la solution à 1 280 pixels : les dix animaux à adopter, de Biscotte à Simba, en trois colonnes de cartes, chacune avec le pictogramme de son espèce sur fond vert pâle, un badge d'espèce, le nom et le refuge

La rangée de filtres, au-dessus de la grille, vient du bonus 17.8.

Critères

  • La liste affiche une carte par ligne à 375 pixels, deux à partir de 640, et trois à partir de 1 024.
  • Dans une même ligne, les cartes ont la même hauteur, comme au catalogue du chapitre 14.
  • Sur la fiche d'Oscar, /animaux/5, absent de la liste parce qu'il est adopté, « Adopté » est un <x-badge variant="adopted"> vert, à côté du badge de l'espèce.
  • Les traits de la fiche sont des badges variant="tag", qui passent à la ligne quand la place manque.

17.2 Ajouter un animal (25 minutes)

Ajoutez create() et store(), et les routes animals.create et animals.store. La vue animals/create.blade.php envoie en POST cinq champs : name, species, shelter_id, birth_date et description. Les traits arrivent au kata 17.5. Écrivez les champs avec les composants du dépôt, comme le formulaire de la friterie au chapitre 15, section 2. Pour l'instant, store() ne valide rien : il crée l'animal avec $request->only([...]), puis redirige vers sa fiche avec un message flash. Affichez ce message dans le layout, comme au chapitre 15, section 5, à l'endroit marqué par un commentaire.

php artisan test --group=ajouter
Test Ce que vous écrivez
GET /animaux/nouveau affiche un formulaire qui envoie en POST vers /animaux <form method="POST" action="{{ route('animals.store') }}">, @csrf, les cinq champs, et les refuges passés par create()
POST /animaux enregistre l'animal et redirige vers sa fiche avec un message Animal::create(...), puis redirect()->route('animals.show', $animal->id)->with('status', …)
le message de confirmation s'affiche sur la fiche après l'ajout @session('status') dans le layout

Le <select> des espèces fait une boucle sur App\Enums\Species::cases(), avec value="{{ $species->value }}" et le libellé {{ $species->label() }}.

Critères

  • Le formulaire est centré dans le cadre d'une carte, comme celui de l'exercice 14.7, et chaque libellé est au-dessus de son champ.
  • Après l'ajout, le message s'affiche au-dessus de la fiche, dans un encadré vert pâle au texte vert foncé.
  • Dans l'onglet Éléments, le texte du message est dans une balise qui porte role="status".
  • Rechargée avec F5, la fiche n'affiche plus le message.

Le message s'affiche dans le layout, et non dans animals/show.blade.php : après une suppression, au kata 17.7, Laravel redirige vers la liste. À l'endroit marqué dans <main> :

@session('status')
    <p role="status">{{ $value }}</p>
@endsession

role="status" signale aux lecteurs d'écran un message d'état.

17.3 Valider le formulaire (25 minutes, installation comprise)

Installez les traductions françaises, comme au chapitre 15, section 4 :

composer require laravel-lang/common --dev
php artisan lang:add fr

APP_LOCALE=fr est déjà dans .env.example et dans phpunit.xml. Enregistrez le dossier lang/ dans un commit : la CI ne lance pas lang:add.

Dans store(), remplacez $request->only() par $request->validate(), avec les règles de la section 2 sauf celles de tags, comme au chapitre 15, section 3. Rule::enum(Species::class) refuse toute valeur qui n'est pas celle d'un cas de l'enum. Traduisez les noms des champs dans le tableau attributes de lang/fr/validation.php. Sous chaque champ, affichez le message avec @error, et rendez la valeur tapée avec old().

php artisan test --group=valider
Test Ce que vous écrivez
les traductions françaises sont installées composer require, lang:add fr, et le dossier lang/ enregistré dans un commit
un formulaire vide est refusé : erreurs sur name, species et shelter_id, rien en base required sur les trois champs, et Animal::create($validated)
un nom de plus de 50 caractères est refusé max:50
une espèce inconnue est refusée Rule::enum(Species::class), avec use Illuminate\Validation\Rule; et use App\Enums\Species;
un refuge qui n'existe pas est refusé exists:shelters,id
une date de naissance dans le futur est refusée before_or_equal:today
la date de naissance et la description sont facultatives nullable sur les deux champs
le formulaire réaffiché garde les valeurs tapées et montre le message en français :value="old('name')" dans le champ et @error('name') dessous, pour chaque champ

Le formulaire « Ajouter un animal » de la solution, renvoyé avec deux erreurs : les champs Nom et Espèce bordés de rouge, avec « Le champ nom est obligatoire. » et « Le champ espèce est obligatoire. » en rouge dessous, le refuge choisi toujours sélectionné

Critères

  • Un champ refusé a une bordure rouge, et son message rouge s'affiche juste en dessous.
  • Chaque champ refusé porte aria-invalid="true", et son aria-describedby désigne son propre message : aria-describedby="species-error" sur l'espèce, et id="species-error" sur le message de l'espèce.
  • Chaque message nomme le champ en français, comme « Le champ espèce est obligatoire. »
  • Après un refus, les champs bien remplis gardent leur valeur, le refuge choisi compris.
  • Aucun champ ne porte l'attribut required : envoyé vide, le formulaire part au serveur, et trois messages s'affichent.

Le tableau attributes de Laravel-Lang traduit déjà name en « nom ». species et shelter_id n'y figurent pas, et Laravel écrit alors le nom du champ, avec une espace à la place du trait de soulignement :

Le champ species est obligatoire.
Le champ shelter id est obligatoire.

Ajoutez species, shelter_id et birth_date au tableau. tags y est déjà, traduit par « mots clés » : vous le changerez au kata 17.5. Le message d'une date future finit par « au today » : le paquet ne traduit pas le mot qui suit les deux-points de before_or_equal:today, et aucun test ne lit ce message. Au kata 17.4, la Form Request le remplacera.

4. Modifier, adopter, supprimer

Les katas 17.4 à 17.7 modifient un animal, cochent ses traits, l'adoptent et le suppriment. Ils reprennent le chapitre 16. Le bonus 17.8 se fait à la maison, après la séance.

Kata Groupe À relire
17.4 Modifier un animal (25 minutes) modifier chapitre 16, sections 2 et 3, et section 5 pour messages()
17.5 Les traits de caractère (25 minutes) caracteres chapitre 16, section 5
17.6 Adopter (15 minutes) adopter chapitre 16, section 6
17.7 Supprimer (15 minutes) supprimer chapitre 16, section 7
17.8 Bonus : le refuge et le filtre (30 minutes, à la maison) bonus le composant du chapitre 16, section 5, $request->query() du chapitre 8, section 4, tryFrom() du chapitre 7 de la POO, section 5

17.4 Modifier un animal (25 minutes)

Ajoutez edit() et update(), et les routes animals.edit et animals.update. La vue animals/edit.blade.php reprend le formulaire d'ajout, envoyé vers route('animals.update', $animal->id) avec @method('PUT'), comme au chapitre 16, section 2. Chaque champ est pré-rempli avec old() à deux arguments. Pour les deux listes, @selected() compare chaque option à old('species', $animal->species->value), puis à old('shelter_id', $animal->shelter_id). update() valide avec les règles de 17.3, puis redirige vers la fiche avec « L'animal a été modifié. ». Ajoutez enfin un bouton « Modifier » sur la fiche, avec la prop href de <x-button>.

Rangez de préférence les règles dans une Form Request AnimalRequest, que store() et update() partagent. Faites renvoyer true à authorize(), comme au chapitre 16, section 3 : sinon, chaque envoi répond 403. Les tests acceptent aussi deux validate(), mais le message « au today » reste alors en place.

La Form Request corrige ce message du kata 17.3. Sa méthode facultative messages(), citée au chapitre 16, section 5, remplace le message d'une règle pour un champ, sous la clé champ.règle :

/**
 * Les messages qui remplacent ceux de lang/fr/validation.php.
 *
 * @return array<string, string>
 */
public function messages(): array
{
    return [
        'birth_date.before_or_equal' => 'La date de naissance ne peut pas être dans le futur.',
    ];
}

Aucun test ne lit ce message : vérifiez-le dans le navigateur avec une date de l'an prochain. Les autres messages viennent toujours de lang/fr/validation.php, avec les noms que vous avez ajoutés à son tableau attributes au kata 17.3.

php artisan make:request AnimalRequest
php artisan test --group=modifier
Test Ce que vous écrivez
GET /animaux/{id}/modifier affiche le formulaire pré-rempli, qui envoie en PUT @method('PUT'), et old('name', $animal->name) dans chaque champ
envoyer le formulaire de modification met l'animal à jour et redirige vers sa fiche $animal->update($validated), puis la redirection avec le message
la modification est validée comme l'ajout AnimalRequest, ou les règles de 17.3 dans update()
modifier un animal inconnu répond 404 findOrFail(), ou la liaison de modèle, dans edit() et dans update()

Critères

  • Sur la fiche, « Modifier » est un lien en forme de bouton secondaire, blanc bordé de gris.
  • Le formulaire de modification a la mise en forme du formulaire d'ajout, et son titre nomme l'animal : « Modifier Rex ».
  • La date de naissance de Rex s'affiche dans son champ.
  • Avec une date de naissance de l'an prochain, le formulaire affiche « La date de naissance ne peut pas être dans le futur. ».

Un <input type="date"> n'accepte qu'une valeur de la forme 2021-03-14. Affichée telle quelle, birth_date s'écrit 2021-03-14 00:00:00, et le navigateur laisse le champ vide. Écrivez $animal->birth_date?->format('Y-m-d') : l'opérateur ?-> du chapitre 5 de la POO renvoie null quand la date est inconnue.

17.5 Les traits de caractère (25 minutes)

Dans les deux formulaires, ajoutez une case par trait de caractère, nommée tags[]. Pour ne pas écrire les cases deux fois, rangez tous les champs dans un composant <x-animal-fields :animal="$animal" :shelters="$shelters" :tags="$tags" />, comme au chapitre 16, section 5. create() lui passe alors new Animal, dont l'espèce vaut null. $animal->species->value s'arrête sur Attempt to read property "value" on null : écrivez $animal->species?->value. L'opérateur ?-> du chapitre 5 de la POO renvoie null au lieu de lire value quand l'espèce est absente.

Au formulaire de modification, les traits de l'animal sont pré-cochés avec @checked, comme les allergènes au chapitre 16, section 5. Ajoutez les règles tags et tags.* du tableau de la section 2, et enregistrez les traits cochés avec sync() dans store() et update(). Dans le tableau attributes de lang/fr/validation.php, remplacez « mots clés » par « traits de caractère », et ajoutez 'tags.*' => 'trait de caractère'.

php artisan make:component animal-fields --view
php artisan test --group=caracteres
Test Ce que vous écrivez
le formulaire d'ajout propose une case par trait de caractère les traits passés par create() et edit(), une case name="tags[]" value="{{ $tag->id }}" par trait
à l'ajout, les traits cochés sont enregistrés dans animal_tag $animal->tags()->sync(...) dans store()
le formulaire de modification pré-coche les traits de l'animal @checked(in_array($tag->id, $checkedTags)) sur chaque case
à la modification, la liste des traits est remplacée sync() dans update()
décocher toutes les cases retire tous les traits sync($validated['tags'] ?? [])
un trait qui n'existe pas est refusé 'tags.*' => ['integer', 'exists:tags,id']

Critères

  • Les huit cases s'affichent sur deux colonnes à 375 pixels, et sur quatre à partir de 640.
  • Les cases sont regroupées dans un <fieldset>, dont la <legend> dit « Traits de caractère ».
  • Un clic sur le nom d'un trait coche ou décoche sa case.
  • Après un refus sur le nom, les cases gardent l'état choisi avant l'envoi, toutes décochées comprises.

@checked(…) écrit l'attribut checked quand sa condition est vraie :

<input type="checkbox" name="tags[]" value="{{ $tag->id }}" @checked(in_array($tag->id, $checkedTags))>

En tête du composant, la liste des cases cochées vient de la session après un refus, et de la base sinon :

@php
    $checkedTags = session()->hasOldInput() ? (array) old('tags', []) : $animal->tags->pluck('id')->all();
@endphp

hasOldInput() dit si un envoi précédent a laissé des valeurs dans la session. Après un refus, une liste absente veut dire qu'aucune case n'était cochée. À l'ajout, new Animal n'a aucun trait. (array) protège d'une valeur seule envoyée hors du navigateur, comme au chapitre 16, section 5. Un navigateur n'envoie pas une case décochée : sans case cochée, tags est absent de la requête, et $validated['tags'] ?? [] donne un tableau vide à sync().

17.6 Adopter (15 minutes)

Ajoutez la route animals.adopt et la méthode adopt(). Sur la fiche d'un animal à adopter, un formulaire d'un bouton « Adopter » envoie la requête avec @method('PATCH'), comme le bouton « Marquer en rupture » de la friterie au chapitre 16, section 6. adopt() pose adopted_at à today() si l'animal n'est pas encore adopté, puis redirige vers la fiche avec « Rex a trouvé une famille. », écrit avec le nom de l'animal. today() renvoie la date du jour à minuit, comme now() au chapitre 16 mais sans l'heure.

php artisan test --group=adopter
Test Ce que vous écrivez
PATCH /animaux/{id}/adoption enregistre la date d'adoption et redirige vers la fiche $animal->update(['adopted_at' => today()]), puis la redirection avec "{$animal->name} a trouvé une famille."
la fiche d'un animal à adopter contient le formulaire d'adoption, celle d'un animal adopté ne le contient plus @if (! $animal->isAdopted()) autour du formulaire
un animal adopté disparaît de la liste rien de plus : le whereNull('adopted_at') du kata 17.1
adopter un animal déjà adopté ne change pas sa date d'adoption un if (! $animal->isAdopted()) dans adopt()
adopter un animal inconnu répond 404 findOrFail(), ou la liaison de modèle

Critères

  • « Adopter » est un bouton principal, vert, de type submit.
  • Après l'adoption, le message s'affiche dans l'encadré vert du kata 17.2, et le badge remplace le bouton.
  • L'animal adopté n'apparaît plus sur /animaux.

Le test appelle $animal->fresh(), qui relit la ligne dans la base. Un $animal->adopted_at = today() sans save() ne change que l'objet en mémoire, et la base garde null. update() change la propriété et enregistre la ligne en une fois. Sans le if, le test « adopter un animal déjà adopté… », vert au départ, devient rouge.

17.7 Supprimer (15 minutes)

Ajoutez la route animals.destroy et la méthode destroy(). Sur la fiche, ajoutez un formulaire POST avec @csrf, @method('DELETE') et un bouton <x-button type="submit" variant="danger">. Il demande une confirmation avec onsubmit="return confirm('Supprimer cet animal ?')", comme au chapitre 16, section 7. <x-button> écrit type="button" par défaut : sans type="submit", le clic n'envoie rien. destroy() supprime l'animal, puis redirige vers la liste avec « L'animal a été supprimé. ».

php artisan test --group=supprimer
Test Ce que vous écrivez
la fiche contient un formulaire de suppression qui envoie en DELETE <form method="POST" action="{{ route('animals.destroy', $animal->id) }}"> avec @method('DELETE')
DELETE /animaux/{id} supprime l'animal et redirige vers la liste $animal->delete(), puis redirect()->route('animals.index')->with('status', …)
supprimer un animal supprime aussi ses lignes dans animal_tag rien de plus : cascadeOnDelete() est dans la migration de animal_tag
supprimer un animal inconnu répond 404 findOrFail(), ou la liaison de modèle

La fiche de Rex dans la solution, juste après une modification : l'encadré vert « L'animal a été modifié. », le pictogramme du chien à gauche, à droite le badge Chien, le nom, la description, le refuge, l'âge et trois traits, puis les boutons Modifier, Adopter et Supprimer

Critères

  • Les trois boutons de la fiche ont trois variantes : « Modifier » secondaire, « Adopter » principal, « Supprimer » rouge.
  • Les trois boutons sont sur une ligne, et passent à la ligne quand la place manque.
  • Un clic sur « Supprimer » ouvre une boîte de confirmation, et « Annuler » laisse l'animal en place.
  • Un clic sur « Supprimer », puis sur OK, ramène à la liste sous « L'animal a été supprimé. ».
  • Au clavier, chaque bouton reçoit un contour de sa couleur.

5. Bonus

17.8 Bonus : le refuge et le filtre (30 minutes, à la maison)

Créez ShelterController, la route shelters.show et la vue shelters/show.blade.php : le nom et la ville du refuge, puis ses animaux à adopter, dans la grille de la liste. Sur la fiche et sur les cartes, le nom du refuge devient un lien vers sa page.

Sur /animaux, ajoutez un filtre par espèce : trois liens vers route('animals.index', ['espece' => 'chat']) et ses voisines, qui ajoutent ?espece=chat à l'adresse. Dans index(), $request->query('espece', '') lit la valeur, ou une chaîne vide. Species::tryFrom(), vue au chapitre 7 de la POO, la change en cas de l'enum, ou renvoie null si elle est inconnue : ne filtrez que dans le premier cas. Pour aller plus loin, remplacez confirm() par une boîte <dialog>, ouverte par sa méthode showModal() et fermée par close(). Son script va dans resources/js/app.js, comme celui du menu à l'exercice 14.5.

php artisan make:controller ShelterController
php artisan test --group=bonus
Test Ce que vous écrivez
GET /refuges/{id} affiche le refuge, sa ville et ses animaux à adopter $shelter->animals()->whereNull('adopted_at')->orderBy('name')->get()
GET /refuges/999 répond 404 findOrFail(), ou la liaison de modèle
/animaux?espece=chat ne garde que les chats where('species', $species) quand tryFrom() a trouvé un cas
une espèce inconnue dans le filtre est ignorée aucun filtre quand tryFrom() renvoie null

Critères

  • Le lien de l'espèce choisie se distingue des autres, et porte aria-current="page".
  • La page d'un refuge reprend les cartes de la liste sans les recopier, avec un composant <x-animal-card :animal="$animal" />, comme <x-animal-fields> au kata 17.5.
  • La boîte <dialog> a les couleurs du site, et se ferme avec Échap.

À retenir

  • Les tests d'un kata sont son énoncé, et le fichier du test montre comment chaque phrase est vérifiée.
  • Une route imposée fixe le verbe, l'adresse et le nom, et laisse libres le contrôleur et la vue.
  • Le message flash s'affiche dans le layout, sur la page où mène la redirection.
  • Un navigateur n'envoie pas une case décochée, et sync() reçoit alors un tableau vide.
  • Tout ce qui modifie la base passe par un formulaire POST, avec @method() pour PUT, PATCH ou DELETE.

Six cartes

Pourquoi le menu du layout est-il écrit en dur ?

Tant que la route animals.index n'existe pas, route('animals.index') lève une exception, et toutes les pages planteraient.

Pourquoi afficher le message flash dans le layout ?

Après une suppression, la redirection mène à la liste.

Pourquoi enregistrer le dossier lang/ dans un commit ?

La CI ne lance pas lang:add fr.

Après un refus, pourquoi session()->hasOldInput() ?

Une case décochée n'est pas envoyée. Sans cette condition, une liste vide après un refus ferait revenir les traits enregistrés.

Une case décochée, que reçoit le serveur ?

Rien : le champ tags est absent, et sync($validated['tags'] ?? []) retire tous les traits.

Pourquoi le test d'adoption appelle-t-il fresh() ?

Il relit l'animal dans la base. Une propriété changée sans save() ne modifie que l'objet en mémoire.