Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Fullcalendar (partie 3)
Samedi 13 décembre 2025 18:13

Grâce au travail effectué lors des deux précédents articles, nous disposons désormais d'un calendrier esthétique et pratique. Un simple clic sur un événement permet d'ouvrir une page modale avec son détail. Un bouton situé sur cette page permet de supprimer l'événement concerné. Nous avons également permis de modifier rapidement la durée d'un événement allday et de déplacer tous les événements à l'aide de la souris. Nous allons maintenant permettre d'ajouter un événement, ce qui est la moindre des choses pour un agenda. Il faudra aussi prévoir la modification d'un événement et sans doute on aura pas mal de code commun à ces deux actions.

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

Créer un événement

Pour créer un nouvel événement, il nous faut un formulaire. L'idéal est d'ouvrir une fenêtre modale pour l'héberger lorsqu'on clique sur un élément du calendrier : jour, heure... 

Sélection

Par défaut, vous ne pouvez pas faire de sélection. Il faut modifier la propriété selectable pour obtenir cet effet :

document.addEventListener('DOMContentLoaded', function () {
      ...
      selectable: true,

Quand vous cliquez sur un jour, il change de couleur pour bien montrer la sélection :

On va étendre cette possibilité de sélection sur plusieurs jours (ou horaires selon l'aspect du calendrier) en changeant une autre propriété :

document.addEventListener('DOMContentLoaded', function () {
    ...
    selectMirror: true,

En cliquant puis en faisant glisser, on peut sélectionner plusieurs jours :

FullCalendar a un événement select qu'on va utiliser pour récupérer toutes les informations d'une sélection. Regardez cette démonstration sur le site officiel. Prenez le temps de passer en vue semaine et journée pour bien voir toutes les possibilités et comprendre le fonctionnement.

Les propriétés

On prépare le terrain dans la classe :

class Calendar extends Component
{
    ...
    public bool $showEventModal = false;
    public bool $isEditMode = false;

    public $eventForm = [
        'id' => null,
        'title' => '',
        'description' => '',
        'location' => '',
        'startDate' => null,
        'startTime' => null,
        'endDate' => null,
        'endTime' => null,
        'allDay' => false,
        'color' => '#3788d8',
    ];

La variable $showEventModal est destinée à gérer la présence ou absence de la page modale.

La variable $isEditMode nous indiquera si on est en mode de création d'événement ou de modification.

La variable $eventForm va stocker toutes les données du formulaire.

Ouvrir la modale en mode création

On prévoit une fonction pour ouvrir la page modale en mode création :

use Illuminate\Support\Str;

public function openCreateModal($start, $end, $allDay): void
{
    $this->reset('eventForm');
    $this->isEditMode = false;
    $this->eventForm['id'] = (string) Str::uuid();
    $this->eventForm['allDay'] = $allDay;

    $startCarbon = Carbon::parse($start);
    $endCarbon = Carbon::parse($end);

    $this->eventForm['startDate'] = $startCarbon->format('Y-m-d');
    if ($allDay) {
        $this->eventForm['endDate'] = $endCarbon->subDay()->format('Y-m-d');
        $this->eventForm['startTime'] = null;
        $this->eventForm['endTime'] = null;
    } else {
        $this->eventForm['startTime'] = $startCarbon->format('H:i');
        $this->eventForm['endDate'] = $endCarbon->format('Y-m-d');
        $this->eventForm['endTime'] = $endCarbon->format('H:i');
    }

    $this->showEventModal = true;
}

On va recevoir du calendrier trois informations, puisqu'on va cliquer sur une zone qui correspond à des heures ou des jours :

  1. la date du début $start
  2. la date de fin $end
  3. le type d'événement $allDay

La plus grande difficulté réside dans la bonne gestion des dates. On renseigne le tableau eventForm avec des valeurs par défaut et on ouvre la modale. On verra plus loin le cas de la modification d'un événement.

Enregistrer un nouvel événement

On ajoute les fonctions destinées à récupérer les informations issues du formulaire dans le calendrier et à sauvegarder celles-ci dans la table events. On va avoir pas mal de code commun à la création et la modification.

On commence par coder une fonction générale :

public function saveEvent(): void
{
    // Validation
    $this->validateEvent();

    // Préparation des dates
    if (!$this->prepareEventDates()) {
        return;
    }

    // Sauvegarde (création ou mise à jour)
    $event = $this->isEditMode
        ? $this->updateExistingEvent()
        : $this->createNewEvent();

    if (!$event) {
        return;
    }

    // Fermer la modale et notifier
    $this->finalizeEventSave($event);
}

On ajoute la validation :

private function validateEvent(): void
{
    $rules = [
        'eventForm.title' => 'required|string|max:255',
        'eventForm.description' => 'nullable|string|max:1000',
        'eventForm.location' => 'nullable|string|max:255',
        'eventForm.color' => 'required|string',
        'eventForm.startDate' => 'required|date',
        'eventForm.endDate' => 'required|date',
    ];

    if (!$this->eventForm['allDay']) {
        $rules['eventForm.startTime'] = 'required';
        $rules['eventForm.endTime'] = 'required';
    }

    $this->validate($rules, $this->getValidationMessages());
}

Avec les messages dans leur fonction dédiée :

private function getValidationMessages(): array
{
    return [
        'eventForm.title.required' => 'Le titre est obligatoire.',
        'eventForm.title.max' => 'Le titre ne peut pas dépasser 255 caractères.',
        'eventForm.startDate.required' => 'La date de début est obligatoire.',
        'eventForm.endDate.required' => 'La date de fin est obligatoire.',
        'eventForm.startTime.required' => 'L\'heure de début est obligatoire.',
        'eventForm.endTime.required' => 'L\'heure de fin est obligatoire.',
    ];
}

On affinera la validation des dates, en particulier le fait que le début doit se situer avant la fin, directement en Javascript.

Ensuite, on prévoit une autre fonction pour la préparation des dates :

private Carbon $startToSave;
private Carbon $endToSave;

private function prepareEventDates(): bool
{
    if (!$this->eventForm['allDay']) {
        $this->startToSave = Carbon::parse($this->eventForm['startDate'] . ' ' . $this->eventForm['startTime']);
        $this->endToSave = Carbon::parse($this->eventForm['endDate'] . ' ' . $this->eventForm['endTime']);

        if ($this->endToSave <= $this->startToSave) {
            $this->addError('eventForm.endDate', 'La fin doit être après le début.');
            return false;
        }
    } else {
        $this->startToSave = Carbon::parse($this->eventForm['startDate']);
        $this->endToSave = Carbon::parse($this->eventForm['endDate']);
    }

    return true;
}

Enfin, on ajoute la création effective de l'événement dans la base :

private function createNewEvent(): ?Event
{
    return Event::create([
        'id' => $this->eventForm['id'],
        'user_id' => auth()->id(),
        'title' => $this->eventForm['title'],
        'start_date' => $this->startToSave,
        'end_date' => $this->endToSave,
        'is_all_day' => $this->eventForm['allDay'],
        'color' => $this->eventForm['color'],
        'description' => $this->eventForm['description'] ?? '',
        'location' => $this->eventForm['location'] ?? '',
    ]);
}

Ensuite, on doit préparer les données pour le calendrier :

private function prepareCalendarData(Event $event): array
{
    $endForCalendar = $this->eventForm['allDay']
        ? Carbon::parse($this->endToSave)->addDay()->toDateString()
        : $event->end_date;

    return [
        'id' => $event->id,
        'title' => $event->title,
        'start' => $event->start_date,
        'end' => $endForCalendar,
        'allDay' => (bool)$event->is_all_day,
        'color' => $event->color,
        'extendedProps' => [
            'description' => $event->description,
            'location' => $event->location,
        ],
    ];
}

Et enfin, on finalise pour envoyer toutes les données au calendrier, fermer la modale et afficher l'alerte de sauvegarde réussie :

private function finalizeEventSave(Event $event): void
{
    $this->showEventModal = false;

    $eventType = $this->isEditMode ? 'eventUpdated' : 'eventCreated';
    $this->dispatch($eventType, $this->prepareCalendarData($event));

    $message = $this->isEditMode
        ? 'Événement modifié avec succès.'
        : 'Événement créé avec succès.';

    session()->flash('message', $message);
}

La touche finale pour fermer la modale en cas d'abandon de l'opération :

public function closeModal(): void
{
    $this->showEventModal = false;
    $this->reset('eventForm');
}

On a beaucoup de fonctions, mais au moins, on respecte le principe de séparation des actions.

La page modale de création

Dans le calendrier, on ajoute la modale :

@if($showEventModal)
    <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
         x-data
         @click.self="$wire.closeModal()">
        <div class="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
            <div class="flex justify-between items-center mb-4">
                <h3 class="text-lg font-bold text-gray-900">
                    {{ $isEditMode ? 'Modifier l\'événement' : 'Nouvel événement' }}
                </h3>
                <button wire:click="closeModal" class="text-gray-400 hover:text-gray-600">
                    <svg class="h-6 w-6" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                              d="M6 18L18 6M6 6l12 12"/>
                    </svg>
                </button>
            </div>

            <form wire:submit.prevent="saveEvent" x-data="{
                get startDateTime() {
                    const date = $wire.eventForm.startDate;
                    const time = $wire.eventForm.startTime || '00:00';
                    return date ? new Date(date + 'T' + time) : null;
                },
                get endDateTime() {
                    const date = $wire.eventForm.endDate;
                    const time = $wire.eventForm.endTime || '00:00';
                    return date ? new Date(date + 'T' + time) : null;
                },
                get isValidRange() {
                    if (!this.startDateTime || !this.endDateTime) return true;
                    if ($wire.eventForm.allDay) {
                        return this.endDateTime >= this.startDateTime;
                    }
                    return this.endDateTime > this.startDateTime;
                },
                get errorMessage() {
                    if (this.isValidRange) return '';
                    if ($wire.eventForm.allDay) {
                        return 'La date de fin doit être égale ou postérieure à la date de début.';
                    }
                    return 'La date et l\'heure de fin doivent être postérieures à la date et l\'heure de début.';
                }
            }">
                <!-- Titre -->
                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2" for="title">
                        Titre <span class="text-red-500">*</span>
                    </label>
                    <input
                        wire:model="eventForm.title"
                        type="text"
                        id="title"
                        class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                        placeholder="Nom de l'événement"
                        autofocus
                    >
                    @error('eventForm.title')
                    <span class="text-red-500 text-xs mt-1">{{ $message }}</span>
                    @enderror
                </div>

                <!-- Description -->
                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2" for="description">
                        Description
                    </label>
                    <textarea
                        wire:model="eventForm.description"
                        id="description"
                        rows="3"
                        class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                        placeholder="Ajoutez une description..."
                    ></textarea>
                </div>

                <!-- Lieu -->
                <div class="mb-4">
                    <label class="block text-gray-700 text-sm font-bold mb-2" for="location">
                        Lieu
                    </label>
                    <input
                        wire:model="eventForm.location"
                        type="text"
                        id="location"
                        class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                        placeholder="Où se déroule l'événement ?"
                    >
                </div>

                <!-- Événement toute la journée -->
                <div class="mb-4">
                    <label class="flex items-center">
                        <input
                            type="checkbox"
                            wire:model.live="eventForm.allDay"
                            class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
                        >
                        <span class="ml-2 text-gray-700 text-sm font-bold">Événement toute la journée</span>
                    </label>
                </div>

                <!-- Dates et heures -->
                <div class="mb-4">
                    <!-- Date de début -->
                    <div class="mb-3">
                        <label class="block text-gray-700 text-sm font-bold mb-2">
                            Début <span class="text-red-500">*</span>
                        </label>
                        <div class="grid {{ $eventForm['allDay'] ? 'grid-cols-1' : 'grid-cols-2' }} gap-2">
                            <input
                                wire:model.live="eventForm.startDate"
                                type="date"
                                class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                            >
                            @if(!$eventForm['allDay'])
                                <input
                                    wire:model.live="eventForm.startTime"
                                    type="time"
                                    class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                                >
                            @endif
                        </div>
                    </div>

                    <!-- Date de fin -->
                    <div>
                        <label class="block text-gray-700 text-sm font-bold mb-2">
                            Fin <span class="text-red-500">*</span>
                        </label>
                        <div class="grid {{ $eventForm['allDay'] ? 'grid-cols-1' : 'grid-cols-2' }} gap-2">
                            <input
                                wire:model.live="eventForm.endDate"
                                type="date"
                                class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                                :class="{ 'border-red-500': !isValidRange }"
                            >
                            @if(!$eventForm['allDay'])
                                <input
                                    wire:model.live="eventForm.endTime"
                                    type="time"
                                    class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
                                    :class="{ 'border-red-500': !isValidRange }"
                                >
                            @endif
                        </div>
                        <span
                            x-show="!isValidRange && errorMessage"
                            x-text="errorMessage"
                            class="text-red-500 text-xs mt-1 block"
                        ></span>
                        @error('eventForm.endDate')
                        <span class="text-red-500 text-xs mt-1 block">{{ $message }}</span>
                        @enderror
                        @error('eventForm.endTime')
                        <span class="text-red-500 text-xs mt-1 block">{{ $message }}</span>
                        @enderror
                    </div>
                </div>

                <!-- Couleur -->
                <div class="mb-6">
                    <label class="block text-gray-700 text-sm font-bold mb-2" for="color">
                        Couleur
                    </label>
                    <div class="flex gap-2">
                        @foreach(['#3788d8', '#22c55e', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899'] as $color)
                            <button
                                type="button"
                                wire:click="$set('eventForm.color', '{{ $color }}')"
                                class="w-8 h-8 rounded-full border-2 transition-all {{ $eventForm['color'] === $color ? 'border-gray-800 scale-110' : 'border-gray-300' }}"
                                style="background-color: {{ $color }}"
                            ></button>
                        @endforeach
                    </div>
                </div>

                <!-- Boutons -->
                <div class="flex justify-end gap-2">
                    <button
                        type="button"
                        wire:click="closeModal"
                        class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 transition"
                    >
                        Annuler
                    </button>
                    <button
                        type="submit"
                        class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
                        wire:loading.attr="disabled"
                        :disabled="!isValidRange"
                    >
                        <span wire:loading.remove>{{ $isEditMode ? 'Modifier' : 'Créer' }}</span>
                        <span wire:loading>{{ $isEditMode ? 'Modification...' : 'Création...' }}</span>
                    </button>
                </div>
            </form>
        </div>
    </div>
@endif

Rien de bien particulier à part qu'on prévoit une validation d'ordre des dates avec Alpine.

Il faut aussi ajouter le code pour deux choses : envoyer un événement lorsqu'on sélectionne une zone et récupérer les données du nouvel événement et le créer effectivement dans le calendrier :

document.addEventListener('DOMContentLoaded', function () {
                     ...
                    select: function (info) {
                        @this.call('openCreateModal', info.startStr, info.endStr, info.allDay);
                        calendar.unselect();
                    }
                });
                calendar.render();

                window.addEventListener('eventCreated', (event) => {
                    const newEventData = event.detail[0];
                    calendar.addEvent(newEventData);
                });

Action !

Il n'y a plus qu'à vérifier que tout ça fonctionne ! On clique sur un jour et...

On a bien notre formulaire dans sa page modale. On peut vérifier les validations :

Vérifiez que les événements se créent correctement.

Modifier un événement

À présent, voyons comment modifier un événement. Ça va être plus simple parce qu'on a déjà presque tout le code. Dans la classe on ajoute cette fonction :

public function editEvent(): void
{
    if (!$this->selectedEvent) {
        return;
    }

    $this->isEditMode = true;

    $this->eventForm['id'] = $this->selectedEvent['id'];
    $this->eventForm['title'] = $this->selectedEvent['title'];
    $this->eventForm['description'] = $this->selectedEvent['description'];
    $this->eventForm['location'] = $this->selectedEvent['location'];
    $this->eventForm['allDay'] = $this->selectedEvent['is_all_day'];
    $this->eventForm['color'] = $this->selectedEvent['color'];

    if ($this->selectedEvent['is_all_day']) {
        $this->eventForm['startDate'] = Carbon::parse($this->selectedEvent['start_date'])->format('Y-m-d');
        $this->eventForm['endDate'] = Carbon::parse($this->selectedEvent['end_date'])->format('Y-m-d');
        $this->eventForm['startTime'] = null;
        $this->eventForm['endTime'] = null;
    } else {
        $startCarbon = Carbon::parse($this->selectedEvent['start_date']);
        $endCarbon = Carbon::parse($this->selectedEvent['end_date']);

        $this->eventForm['startDate'] = $startCarbon->format('Y-m-d');
        $this->eventForm['startTime'] = $startCarbon->format('H:i');
        $this->eventForm['endDate'] = $endCarbon->format('Y-m-d');
        $this->eventForm['endTime'] = $endCarbon->format('H:i');
    }

    $this->showDetailModal = false;
    $this->showEventModal = true;
}

On renseigne le tableau evenForm avec les données de l'événement, on met en forme les dates et on ouvre la modale en précisant qu'on est en mode modification (isEditMode).

Ensuite on ajoute cette fonction pour la mise à jour effective de l'évement dans la base :

private function updateExistingEvent(): ?Event
{
    $event = Event::find($this->eventForm['id']);

    if (!$event || $event->user_id !== Auth::id()) {
        return null;
    }

    $event->update([
        'title' => $this->eventForm['title'],
        'start_date' => $this->startToSave,
        'end_date' => $this->endToSave,
        'is_all_day' => $this->eventForm['allDay'],
        'color' => $this->eventForm['color'],
        'description' => $this->eventForm['description'] ?? '',
        'location' => $this->eventForm['location'] ?? '',
    ]);

    return $event;
}

Dans le calendrier, dans la page modale qui affiche le détail d'un événement, on ajoute le bouton de modification :

<!-- Boutons d'action -->
<div class="flex justify-end gap-2 mt-6 pt-4 border-t">
    <button
        wire:click="deleteEvent"
        wire:confirm="Êtes-vous sûr de vouloir supprimer cet événement ?"
        class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition"
    >
        Supprimer
    </button>
    <button
        wire:click="editEvent"
        class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
    >
        Modifier
    </button>
</div>

Il faut aussi ajouter une écoute d'événement pour la modification :

window.addEventListener('eventUpdated', (event) => {
    const updatedData = event.detail[0];
    const calendarEvent = calendar.getEventById(updatedData.id);
    if (calendarEvent) {
        calendarEvent.setProp('title', updatedData.title);
        calendarEvent.setStart(updatedData.start);
        calendarEvent.setEnd(updatedData.end);
        calendarEvent.setAllDay(updatedData.allDay);
        calendarEvent.setProp('color', updatedData.color);
        calendarEvent.setExtendedProp('description', updatedData.extendedProps.description);
        calendarEvent.setExtendedProp('location', updatedData.extendedProps.location);
    }
});

À présent, quand on clique sur un événement, la modale est bien complétée avec le bouton de modification :

Et lorsqu'on clique sur ce bouton, on ouvre bien le formulaire :

On en a fini avec la modification.

Conclusion

Nous avons bien avancé dans la réalisation de notre agenda. Il est maintenant pleinement fonctionnel !



Par bestmomo

Aucun commentaire