В данной заметке из трёх частей рассматривается построение календаря, отображающего события, связанные с разнородными сущностями системы. Бэк-энд реализован на 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. А в третьей — заставим синюю кнопку «Добавить событие» делать что-то осмысленное.