Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Liveware Fullcalendar
Mardi 27 avril 2021 22:30

Je continue l'exploration des possibilités de Livewire, cette fois associé à la plus célèbre librairie de calendrier : Fullcalendar. C'est une librairie très complète avec un affichage esthétique sous différentes formes d'un calendrier avec des événements. Il est totalement paramétrable et gratuit et open source pour l'essentiel de ses possibilités. Sur le site officiel on trouve de nombreuses démonstrations : glisser-déposer d'événement, entrée d'événement avec un clic sur le jour, événements en fond, changement de thème ou de locale... On a là tout ce qu'il faut pour traiter une application fondée sur des événements temporels.

C'est la lecture de cet article qui m'a donné envie de m'intéresser à cette histoire.

Fullcalendar est une librairie frontend qui fonctionne en Javascript, il n'y a donc aucune persistance des données. Livewire est très doué pour gérer ce genre de situation sans qu'on se soucie de la gestion des requêtes Ajax. On va voir dans cet article comment articuler les deux.

Vous pouvez télécharger le code final de cet article ici.

Installation

Laravel

On fait une nouvelle installation de Laravel :
composer  create-project laravel/laravel livewirecalendar --prefer-dist
On crée une base de données et on informe le fichier .env :
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=livewirecalendar
DB_USERNAME=root
DB_PASSWORD=
On peut alors créer les tables de base :
php artisan migrate

Livewire

Maintenant on installe Livewire :
composer require livewire/livewire
Et un composant :
php artisan make:livewire calendar
On a ainsi créé une classe : Et une vue :

Route et vue

On crée une vue pour l'affichage du calendrier :

Là on va faire simple :
<html>
<head>
    @livewireStyles
</head>
<body>
    <livewire:calendar />
    @livewireScripts
    @stack('scripts')
</body>
</html>

Par défaut Livewire ne supporte pas la balise <script> directement dans la vue d'un composant (c'est expliqué ici). Blade propose la directive @stack qui permet de contourner cette limitation. On crée ici une pile nommée et on pourra pousser (@push) un script à cet emplacement.

On crée aussi la route pour afficher cette vue :
Route::get('/', function () {
    return view('home');
});

On crée un calendrier

Maintenant on peut se lancer en créant le premier calendrier. On code le composant Livewire :
<style>
    #calendar-container {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
    }
    #calendar {
        margin: 10px auto;
        padding: 10px;
        max-width: 1100px;
        height: 700px;
    }
</style>

<div>
    <div id='calendar-container' wire:ignore>
        <div id='calendar'></div>
    </div>
</div>

@push('scripts')

<script src='https://cdn.jsdelivr.net/npm/fullcalendar@5.6.0/main.min.js'></script>

<script>
    document.addEventListener('livewire:load', function () {
        const Calendar = FullCalendar.Calendar;
        const calendarEl = document.getElementById('calendar');
        const calendar = new Calendar(calendarEl);
        calendar.render();
    });
</script>

<link href='https://cdn.jsdelivr.net/npm/fullcalendar@5.6.0/main.min.css' rel='stylesheet' />

@endpush

On charge Fullcalendar avec son CDN. Ensuite on a un simple conteneur et la prestation minimale pour le calendrier.

Pour le moment Livewire ne nous sert à rien, mais c'est juste pour voir si le calendrier s'affiche correctement :

On a les réglages par défaut : affichage du mois courant et boutons pour le défilement par mois.

On peut déjà modifier la locale :
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.6.0/locales-all.min.js"></script>

<script>
    document.addEventListener('livewire:load', function () {
        ...
        const calendar = new Calendar(calendarEl, {
            locale: '{{ config('app.locale') }}',
        });
        ...

Si on met la locale en fr on a tout en français :

Dommage qu'il n'y ait pas une majuscule pour le mois. D'ailleurs les noms des mois ne se trouvent pas dans le fichier JS chargé avec le CDN. Je n'ai pas cherché plus loin, mais j'ai remarqué que moment.js est utilisé...

On va aussi enrichir et réorganiser la barre de boutons :

const calendar = new Calendar(calendarEl, {
    headerToolbar: {
        left: 'prev,next today',
        center: 'title',
        right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
    },
    locale: '{{ config('app.locale') }}',
});

On peut maintenant facilement changer l'affichage : par mois, par semaine, par jour, sous forme de liste d'événements.

Mais notre calendrier est vide et on n'a encore aucun moyen de le remplir...

Persistance des données

Maintenant qu'on sait comment créer un calendrier il va falloir le remplir. Côté Laravel on crée une table (avec modèle et seeder) pour mémoriser les événements :

php artisan make:model Event -ms

Pour les colonnes de notre table, il faut déjà savoir quelles sont les informations utilisées par Fullcalendar pour ses événements. On trouve l'objet Event dans la documentation. On se rend compte qu'il y a beaucoup de possibilités. On va rester simple et prévoir seulement :

  • id : l'identifiant de l'événement
  • title : le titre de l'événement tel qu'il apparaît
  • start : date et heure éventuelle de début de l'événement
  • end : date et heure de fin de l'événement (si null c'est que l'événement concerne la journée entière)

On code en conséquence la migration :

public function up()
{
    Schema::create('events', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->string('title');
        $table->string('start');
        $table->string('end')->nullable();
    });
}

Avec Eloquent on n'est pas obligé d'avoir une clé primaire en nombre entier auto-incrémenté, ici j'utilise un uuid. Il serait délicat, pour ne pas dire impossible, d'utiliser dans le calendrier des entiers en série continue comme identifiant et ensuite de les gérer simplement.

Dans le modèle Event on prévoit l'assignement de masse, que notre clé n'est pas un entier auto-incrémenté et on précise également qu'on n'aura pas de timestamp :

public $timestamps = false;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['id', 'title', 'start', 'end',];
Dans le seeder on code 3 événements :
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Event;
use Illuminate\Support\Str;

class EventSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Event::insert([
            [
                'id' => Str::uuid(),
                'title' => 'Evenement 1',
                'start' => '2021-04-02',
                'end' => null
            ],
            [
                'id' => Str::uuid(),
                'title' => 'Evenement 2',
                'start' => '2021-04-10T08:00:00',
                'end' => '2021-04-10T16:00:00'
            ],
            [
                'id' => Str::uuid(),
                'title' => 'Evenement 3',
                'start' => '2021-04-20',
                'end' => '2021-04-22'
            ],
        ]);
    }
}
Dans Databaseeder il faut appeler la classe créée :
public function run()
{
    $this->call([EventSeeder::class,]);
}
On lance :
php artisan migrate --seed

Maintenant qu'on a des événements on utilise Livewire pour les afficher :

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use App\Models\Event;

class Calendar extends Component
{
    public $events = [];

    public function render()
    {
        $this->events = json_encode(Event::all());
        return view('livewire.calendar');
    }
}
Dans le composant on récupère ces événements :
document.addEventListener('livewire:load', function () {
    ...
    const calendar = new Calendar(calendarEl, {
        ...
        events: JSON.parse(@this.events)
    });        
    calendar.render();
});
On vérifie sur le calendrier :

En affichage semaine on a le détail de l'événement 2:

On avance ! On peut rendre les événements sur le calendrier modifiable :
const calendar = new Calendar(calendarEl, {
    ...
    editable: true,
});

On peut maintenant modifier les événements, mais évidemment ça ne met pas à jour notre base de données !

Répercussion des modifications

Ajustement de la fin d'un événement en changeant sa dimension

On peut modifier un événement en ajustant la fin. L'événement déclenché est eventResize. Livewire va nous permettre de gérer ça facilement. Dans le composant :

const calendar = new Calendar(calendarEl, {
    ...
    eventResize: info => @this.eventChange(info.event)
});
Et dans la classe on crée une méthode pour la mise à jour dans la base :
...
use Illuminate\Support\Arr;

class Calendar extends Component
{
    public $events = [];

    public function eventChange($event)
    {
        $e = Event::find($event['id']);
        $e->start = $event['start'];
        if(Arr::exists($event, 'end')) {
            $e->end = $event['end'];
        }
        $e->save();
    }
    ...

Ici la méthode est généralisée pour pouvoir être utilisée aussi pour le glisser-déposer qu'on va voir juste après. On teste l'existence de la donnée de fin d'événement parce que les événements d'une journée ne comportent pas cette information.

Glisser-déposer

On peut aussi déplacer un événement avec un glisser-déposer et dans ce cas on modifie à la fois le début et la fin. L'événement déclenché est eventDrop. On ajoute la gestion de cet événement dans le composant :

const calendarEl = document.getElementById('calendar');
    const calendar = new Calendar(calendarEl, {
        ...
        eventDrop: info => @this.eventChange(info.event)
    });

Dans la classe on utilise la même méthode qu'on a créée précédemment.

Notre calendrier a avancé ! Nos événements sont stockés dans une table, on les affiche au chargement et ensuite on peut les modifier directement sur le calendrier et les modifications sont automatiquement répercutées dans la table.

Ajouter un événement

Il faut aussi pouvoir ajouter des événements sur le calendrier, pour le moment on n'en a pas la possibilité. Il existe deux manières pour ajouter un événement : utiliser l'événement select pour ouvrir par exempleune boîte de dialogue à la suite d'un clic sur un jour ou une série de jours ou glisser-déposer un événement sur le calendrier.

Boîte de dialogue

Par défaut la fonctionnalité de sélection est désactivée, il faut donc l'activer :

const calendar = new Calendar(calendarEl, {
    ...
    selectable: true,
});

Maintenant avec un clic sur un jour on déclenche l'événement select. On ajoute le code dans le composant :

create_UUID = () => {
    let dt = new Date().getTime();
    const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        let r = (dt + Math.random() * 16) % 16 | 0;
        dt = Math.floor(dt / 16);
        return (c == 'x' ? r :(r&0x3|0x8)).toString(16);
    });
    return uuid;
}

document.addEventListener('livewire:load', function () {
    const Calendar = FullCalendar.Calendar;
    const calendarEl = document.getElementById('calendar');
    const calendar = new Calendar(calendarEl, {
        headerToolbar: {
            left: 'prev,next today',
            center: 'title',
            right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
        },
        locale: '{{ config('app.locale') }}',
        events: JSON.parse(@this.events),
        editable: true,                
        eventResize: info => @this.eventChange(info.event),
        eventDrop: info => @this.eventChange(info.event),
        selectable: true,
        select: arg => {
            const title = prompt('Titre :');
            const id = create_UUID();
            if (title) {
                calendar.addEvent({
                    id: id,
                    title: title,   
                    start: arg.start,
                    end: arg.end,
                    allDay: arg.allDay
                });
                @this.eventAdd(calendar.getEventById(id));
            };
            calendar.unselect();
        },
    });        
    calendar.render();
});

J'ai copié tout le script pour ceux qui risquaient de se perdre.

  On fait plusieurs choses :
  • on définit le texte de la boîte de dialogue
  • on définit l'uuid (j'ai prévu une fonction que j'ai récupérée ici)
  • on crée l'événement dans le calendrier
  • on utilise Livewire pour envoyer les informations pour la mise à jour de la base
Dans la classe on crée la méthode :
public function eventAdd($event)
{
    Event::create($event);
}

On peut maintenant ajouter des événements ! Par défaut ils occupent la journée entière.

Glisser-déposer

La deuxième façon d'ajouter un événement est d'en avoir des disponibles à côté du calendrier et de les faire glisser sur un jour du calendrier. Dans la documentation on trouve des explications ici. C'est sommaire mais précis.

On va un peu changer la mise en page parce qu'on doit ajouter les événement à glisser-déposer. Voici le nouveau HTML et style :

<style>
    #calendar-container {
        display: grid;
        grid-template-columns: 200px 1fr;
        padding: 20px;
    }
    #events {
        grid-column: 1;
    }
    #calendar {
        grid-column: 2;
        height: 700px;
    }
    .dropEvent {
        background-color: DodgerBlue;
        color: white;
        padding: 5px 16px;
        margin-bottom: 10px;
        text-align: center;
        display: inline-block;
        font-size: 16px;
        border-radius: 4px;
        cursor:pointer;
    }
</style>

<div>
    <div id="calendar-container" wire:ignore>
        <div id="events">
            <div data-event='{"title":"Evénement A"}' class="dropEvent">Evénement A</div>
            <div data-event='{"title":"Evénement B"}' class="dropEvent">Evénement B</div>
        </div>
        <div id="calendar"></div>
    </div>
</div>

Pour la mise en page j'utilise Grid et pour le reste c'est du CSS classique. On a deux événements à glisser à côté du calendrier :

Comme expliqué dans la documentation les informations des événements sont placés en JSON dans un dataset :

<div data-event='{"title":"Evénement A"}' class="dropEvent">Evénement A</div>

Il faut utiliser la classe Draggable et lui dire où trouver les événements à glisser, ici j'utilise la classe :

document.addEventListener('livewire:load', function () {
    ...
    const Draggable = FullCalendar.Draggable;
    new Draggable(document.getElementById('events'), {
        itemSelector: '.dropEvent'
    });

Ensuite on utilise l'événement eventReceive pour récupérer les informations et on utilise encore Livewire pour l'ajout dans la base :

document.addEventListener('livewire:load', function () {
    
    ...

    const calendar = new Calendar(calendarEl, {
        
        ...

        eventReceive: info => {
            const id = create_UUID();
            info.event.setProp('id', id);
            @this.eventAdd(info.event);
        },

    });        
    calendar.render();
});
On génère un nouvel identifiant pour chaque événement. Et ça marche !

Supprimer un événement

Il faut aussi pouvoir supprimer un événement. Curieusement dans toutes les démonstrations du site on n'a jamais le cas...

Alors on va faire simple et juste gérer un clic sur un événement en ouvrant un boîte de confirmation classique :

document.addEventListener('livewire:load', function () {
    
    ...

    const calendar = new Calendar(calendarEl, {

        ...

        eventClick: info => {
            if (confirm("Voulez-vous vraiment supprimer cet événement ?")) {
                info.event.remove();
                @this.eventRemove(info.event.id);
            }
        }

    });        
    calendar.render();
});

Et dans la classe on fait la suppression pour la base :

public function eventRemove($id)
{
    Event::destroy($id);
}

Conclusion

On a vu dans cet article comment gérer Fullcalendar avec Livewire. On en arrive à un code très léger côté Laravel ! J'ai traité les possibilités de base de ce superbe calendrier mais ça ouvre pas mal de perspectives de création d'application à base de gestion temporelle. ca peut rapidement se compliquer si un même calendrier est utilisé par plusieurs personnes et qu'on veut empêcher des simultanéités...



Par bestmomo

Nombre de commentaires : 19