Студия разработки сайтов и приложений

Netspark.ru

Календарь событий на Laravel и Vue.js, часть первая

В данной заметке из трёх частей рассматривается построение календаря, отображающего события, связанные с разнородными сущностями системы. Бэк-энд реализован на Laravel, фронт — на Vue.js. Календарь сделаем с помощью Full Calendar и соответствующей интеграции fullcalendar-vue.

Для определенности будем рассматривать туристическую систему, в которой у нас есть заказы, а у заказов есть дата вылета и дата возврата. И также есть сроки оплаты по данным заказам. Вот эти данные мы и будем отображать на календаре.

В результате получится примерно вот так:

В первой части рассмотрим бэк-энд на Laravel. Что, нам, собственно, нужно от бэк-энда? Нам нужны данные об отображаемых событиях. В первую очередь — дата события, чтобы впихнуть его куда-то в календарь, какой-нибудь заголовок, чтобы было что написать на календаре, и какие-то дополнительные данные, чтобы отличать одно событие от другого.

Исходные сущности (заказы и сроки), которые должны получить отображение на календаре, представлены моделями Order и PaymentDeadline. А в сам календарь должны поступать данные о событиях, приведенные к некоторому единому формату. Для преобразования в этот единый формат у нас есть два варианта действий:

1) Преобразовывать каждую модель заказа и дедлайна на лету, при обращении к API календаря.

2) Выводить заранее подготовленные на базе заказов и дедлайнов модели событий.

Плюсы и минусы у подходов такие. В первом варианте нам не надо ничего делать заранее, но при запросе календарных событий придется грузить разнородные модели, отфильтрованные по своим наборам правил, и для каждой модели запускать преобразование. Отклик сервера увеличится, а код создания событий может стать громоздким и запутанным.

Во втором варианте мы при обращении к API календаря сразу возвращаем коллекцию событий, отфильтрованную по датам. Время исполнения запроса будет меньше, код читабельнее, но нам придётся поддерживать создание-обновление-удаление моделей событий, когда создаются-обновляются-удаляются их «родители» (заказы и дедлайны).

Мы будем рассматривать второй вариант и начнем с описания модели события:

namespace App;

use Illuminate\Database\Eloquent\Model;

class CalendarEvent extends Model
{
    protected $guarded = [];

    protected $with = ['parent'];

    public function parent() {
        return $this->morphTo();
    }

}

Модель я назвал CalendarEvent, чтобы не путать с обычными событиями, которые Event. Пока у модели описана только связь с неким «родителем» — полиморфная, поскольку идея в том, что родитель может быть заказом, или дедлайном, или чем-нибудь еще.

Теперь сделаем события, связанные с заказами (вылет, возврат). Нам нужно, чтобы каждый раз, когда создается, изменяется, или удаляется заказ, изменения отражались и в связанными с заказом событиями.

Определим связь с событиями на классе заказа:

<?php

namespace App;

class Order extends Model
{

    public function calendar_events() {
        return $this->morphMany('App\CalendarEvent', 'parent');
    }

    // ...

}

Создадим класс-наблюдатель для обработки соответствующих событий:

<?php

namespace App\Observers;

use App\CalendarEvent;
use App\Order;

class OrderObserver
{

    /**
     * Срабатывает, когда заказ создан, или обновлен
     */
    public function saved(Order $order) {
        CalendarEvent::makeDepartureEvent($order);
        CalendarEvent::makeReturnEvent($order);
    }

    /**
     * Срабатывает, когда заказ удален
     */
    public function deleted(Order $order) {
        $order->calendar_events->each(function (CalendarEvent $event) {
            $event->delete();
        });
    }

}

Чтобы наблюдатель мог наблюдать, надо не забыть его зарегистрировать:

<?php

namespace App\Providers;

use App\Observers\OrderObserver;
use App\Order;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{

    public function boot()
    {
        Order::observe(OrderObserver::class);
    }

}

Теперь, когда новый заказ создан, или обновлен, будут вызываться статические методы makeDepartureEvent() и makeReturnEvent().

Посмотрим на реализацию:

class CalendarEvent extends Model
{

    protected $guarded = [];

    protected $with = ['parent'];

    public function parent() {
        return $this->morphTo();
    }

    public static function makeDepartureEvent(Order $order) {
        self::makeFlightEvent($order, 'departure');
    }

    public static function makeReturnEvent(Order $order) {
        self::makeFlightEvent($order, 'return_flight');
    }

    /**
     * Код создания событий почти не отличается, поэтому они создаются из одного метода
     */
    protected static function makeFlightEvent(Order $order, $type) {
        $date = ($type == 'departure') ? $order->flight_date : $order->return_date;
        if (!$date) {
            // если дата не определена, удаляем существующее событие (вдруг оно уже было создано ранее)
            $event = $order->calendar_events->filter(function (CalendarEvent $item) use ($type) {
                return $item->type == $type;
            })->first();
            optional($event)->delete();
            return;
        }

        // метод генерирует текст события на базе каких-то данных заказа
        // (этот текст будет видно на календаре)
        // реализуйте его самостоятельно или подставьте любой текст
        $title = static::makeOrderEventTitle($order);

        // метод сначала проверит, есть ли уже ранее созданное событие, и обновит его, если есть
        // а если нет - создаст новое
        static::updateOrCreate([
            'type' => $type,
            'parent_id' => $order->id,
            'parent_type' => Order::class,
        ], [
            'title' => $title,
            'start' => $date,
            // дату окончания не указываем, предполагая, что событие заканчивается в тот же день
            'end' => NULL,
        ]);
    }

}

Теперь то же самое (отношение, наблюдателя, создание события) нужно сделать и для дедлайнов.

Отношение:

<?php

namespace App;

class PaymentDeadline extends Model
{

    public function calendar_events() {
        return $this->morphMany('App\CalendarEvent', 'parent');
    }

    // ...

}

Наблюдатель:

<?php

namespace App\Observers;

use App\CalendarEvent;
use App\PaymentDeadline;

class PaymentDeadlineObserver
{

    public function saved(PaymentDeadline $paymentDeadline)
    {
        CalendarEvent::makePaymentDeadlineEvent($paymentDeadline);
    }

    public function deleted(PaymentDeadline $paymentDeadline)
    {
        $paymentDeadline->calendar_events->each(function (CalendarEvent $event) {
            $event->delete();
        });
    }

}

Не забудем зарегистрировать:

<?php

namespace App\Providers;

use App\Observers\OrderObserver;
use App\Observers\PaymentDeadlineObserver;
use App\Order;
use App\PaymentDeadline;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{

    public function boot()
    {
        Order::observe(OrderObserver::class);
        PaymentDeadline::observe(PaymentDeadlineObserver::class);
    }

}

Создание события:

<?php

class CalendarEvent extends Model
{

    // ...

    public static function makePaymentDeadlineEvent(PaymentDeadline $paymentDeadline) {
        // метод генерирует текст события на базе каких-то данных о сроках платежа
        // (этот текст будет видно на календаре)
        // реализуйте его самостоятельно или подставьте любой текст
        $title = static::makeDeadlineEventTitle($paymentDeadline);

        static::updateOrCreate([
            'type' => 'payment_deadline',
            'parent_id' => $paymentDeadline->id,
            'parent_type' => PaymentDeadline::class,
        ], [
            'title' => $title,
            'start' => $paymentDeadline->due,
            'end' => NULL,
        ]);
    }

}

Теперь нужно создать путь для получения календарных событий за определенный промежуток времени:

<?php

// /routes/web.php

Route::get('/api/calendar', 'CalendarController@index');

И контроллер, реализующий эту логику:

<?php

namespace App\Http\Controllers;

use App\CalendarEvent;
use App\Order;
use App\PaymentDeadline;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class CalendarController extends Controller
{

    public function index(Request $request) {
        // получаем параметры запроса (от - до) через валидацию
        $data = $this->validateCalendarRequest($request);

        /** @var Collection $events */
        // загружаем события в заданном диапазоне
        $events = CalendarEvent::query()
            ->where('start', '>=', $data['start'])
            ->where('start', '<=', $data['end'])
            ->get();

        return response()->json($events, 200);
    }

    /**
     *
     * Валидация параметров запроса событий
     *
     * @param Request $request
     * @return mixed
     */
    protected function validateCalendarRequest(Request $request) {
        return $request->validate([
            'start' => 'required|date',
            'end' => [
                'required',
                'date',
                'after_or_equal:start',
                // Ограничение диапазона - не больше года
                function ($attribute, $value, $fail) use ($request) {
                    try {
                        $start = new Carbon($request->get('start'));
                    } catch (\Exception $e) {
                        return;
                    }
                    $end = new Carbon($value);
                    if ($end->diffInMonths($start) > 12) {
                        $fail('End date must be within a year from start date.');
                    }
                }
            ],
        ]);
    }

}

Вот, собственно, и всё. Такой подход позволит нам, при желании, добавлять и множество других типов событий, независимо друг от друга. И выводить их все через единый интерфейс.

Кстати, обратную связь, то есть метод calendar_events(), на родительских классах объявлять вообще необязательно. Здесь они используются только чтобы удобно было получать коллекцию событий для удаления, или обновления. Но в принципе мы можем то же самое делать по CalendarEvent::where('parent_id', $parent->id) и тогда можно полностью отвязать реализацию календарных событий от кода каждого из их родителей.

На этом базовый бэк-энд можно считать построенным. Во второй части мы создадим соответствующий фронт-энд с календарем на Vue.js. А в третьей — заставим синюю кнопку «Добавить событие» делать что-то осмысленное.

Вторая часть

Комментарии