В первой части статьи мы строили бэкенд на Laravel для вывода разнородных событий на календаре. В этой части мы рассмотрим построение фронт-энда на Vue.js с помощью FullCalendar, для которого есть интеграция fullcalendar-vue. Получится примерно как на картинке.
Что вообще должен делать наш фронт-энд?
Во-первых, загружать из бэк-энда события по созданному ранее пути /api/calendar
.
Во-вторых, отображать эти события в соответствии с установленными для них датами.
В третьих, как-то визуально отличать одно событие от другого, чтобы было нагляднее.
Ну и наконец, для удобства, давать больше информации о событии по наведению на него курсора.
Будем считать эти четыре пункта нашим ТЗ на фронт-энд и приступим к реализации. Установим интеграцию для FullCalendar:
npm install --save @fullcalendar/vue @fullcalendar/core @fullcalendar/daygrid
Создадим новый компонент для календаря и базовые настройки.
<template>
<div class="panel panel-inverse">
<div class="panel-body"><h2 class="text-center"><i class="material-icons">calendar_today</i> Календарь</h2>
<FullCalendar
defaultView="dayGridMonth"
:events="events"
:datesRender="updateDates"
:eventRender="onEventRender"
:plugins="calendarPlugins"
:locale="locale"
themeSystem="bootstrap"
:header="{ right: 'addEventButton dayGridMonth,timeGridWeek prev,today,next' }"
ref="fullCalendar"></FullCalendar>
</div>
</div>
</template>
<script>
import FullCalendar from '@fullcalendar/vue';
import bootstrapPlugin from '@fullcalendar/bootstrap';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import ruLocale from '@fullcalendar/core/locales/ru';
import moment from 'moment';
export default {
data() {
var self = this;
return {
calendarPlugins: [ bootstrapPlugin, dayGridPlugin, timeGridPlugin ],
locale: ruLocale,
events: [],
start: null,
end: null,
activeDate: null
}
},
components: {
FullCalendar
}
}
</script>
<style>
@import '~@fullcalendar/core/main.css';
@import '~@fullcalendar/daygrid/main.css';
@import '~@fullcalendar/timegrid/main.css';
@import '~@fullcalendar/bootstrap/main.css';
</style>
Здесь мы устанавливаем следующие настройки:
- какие компоненты FullCalendar нужно загрузить (ядро календаря, dayGrid, timeGrid, тему на бутстрапе, локализацию, ну и moment.js пригодится);
- указываем что по умолчанию нужно отобразить календарь на месяц (атрибут defaultView);
- говорим, что события нужно брать из массива events в нашем компоненте;
- указываем, что при рендере событий в заданном промежутке нужно вызывать метод компонента
updateDates()
; - добавляем коллбэк, вызываемый при рендере каждого события календаря
onEventRender()
; - говорим, что подключаемые плагины перечислены в массиве calendarPlugins;
- локаль установлена в переменной locale (это ruLocale, импортированная из локалей календаря);
- тема календаря должна быть на основе бутстрапа;
- в атрибуте
header
перечислен порядок и расположение необходимых кнопок над календарем; - также указан атрибут
ref
, который нам понадобится для обращений к объекту календаря.
В конце компонента мы подключаем необходимые стили.
Итак, мы знаем, что события календарь будет брать из массива events
, а значит нам надо их туда загрузить. Начнем с реализации метода updateDates()
нашего компонента — ведь именно он вызывается, когда календарь собирается отобразить события в заданном временном интервале:
export default {
// ...
methods: {
/**
* Метод вызывается, когда пользователь меняет активный интервал дат,
* переключает месяц/неделю, а также при начальной инициализации компонента.
*/
updateDates: function () {
// получаем объект API календаря (см. как мы указали ref выше)
let calendar = this.$refs.fullCalendar.getApi();
if (this.activeDate && (this.activeDate.getTime() == calendar.getDate().getTime())) {
// если просматриваемые даты совпадают с текущими, нет нужды перегружать
return;
}
// сохраняем текущие даты
this.activeDate = calendar.getDate();
var date = moment(this.activeDate);
// выставляем start и end в -1 и +1 месяц относительно текущего
this.start = date.clone().subtract(1, 'months').startOf('month');
this.end = date.clone().add(1, 'months').endOf('month');
// вызываем метод загрузки событий
this.fetchEvents();
},
fetchEvents: function () {
// приводим даты начала и конца интервала в подходящий для бэкенда формат
let start = this.start.format('YYYY-MM-DD');
let end = this.end.format('YYYY-MM-DD');
// обращаемся к бэкэнду для загрузки массива событий
this.$http.get('/api/calendar?' + 'start=' + start + '&end=' + end).then(response => {
// получаем загруженные события и вызываем метод добавления их в календарь
let loadedEvents = response.body;
this.addEvents(loadedEvents);
}, response => {
// выводим ошибки в лог
console.log(response.body);
});
},
// ...
}
}
Обратите внимание, что мы грузим события на 1 месяц раньше и на 1 месяц позже текущего. Это нужно, так как в календаре обычно выводятся последние числа предыдущего и первые числа следующего месяца. Будет странно, если эти числа останутся пустыми, хотя в них попадают какие-нибудь события.
Теперь посмотрим на метод addEvents()
, добавляющий загруженные нами события в календарь. Не забудем, что поскольку пользователь может неоднократно изменять интервал, щелкая вперед-назад, нужно учитывать возможные дубли событий и не добавлять повторно те, что уже существуют.
methods: {
// ...
addEvents: function (events) {
var self = this;
events.forEach(function (item) {
// добавляем каждое событие
self.addEvent(item);
});
},
addEvent: function (new_event) {
// ищем среди уже загруженных событий событие с таким же id
// id уникальны, потому события - это модели CalendarEvent, независимо от типа
let index = this.events.findIndex(event => event.id == new_event.id);
if (index !== -1) {
return;
}
var start;
if (new_event.type == 'payment_deadline') {
// считаем дедлайны событием на весь день (без заданного времени)
start = moment(new_event.start).format('YYYY-MM-DD');
} else {
// а для остальных событий сохраняем время
start = new_event.start;
}
// создаем объект события для вывода в календаре
var event = {
id: new_event.id, // id события
title: new_event.title, // заголовок (текст) события
start: start, // дата начала
end: new_event.end, // дата окончания (или null)
className: 'event-' + new_event.type,
color: this.getEventColor(new_event.type)
};
// задаем ссылку для перехода по клику на событие
if (new_event.type == 'payment_deadline') {
event.url = this.$router.resolve({ name: 'payment_deadline', params: {id: new_event.parent.id} }).href;
} else {
event.url = this.$router.resolve({ name: 'order', params: {id: new_event.parent.id} }).href;
}
event.extendedProps = this.makeExtendedProps(new_event);
// отправляем созданный объект в массив отображаемых событий
this.events.push(event);
},
// ...
}
Остановимся подробно на свойствах className
, color
, url
и extendedProps
. Первые два, как нетрудно догадаться, позволяют задать обертке события свой класс и цвет. Для задания разных классов мы используем тип события, передаваемый в поле type
. Получатся классы event_departure
, event-return_flight
и event-payment_deadline
.
Для получения цвета мы вызываем отдельный метод getEventColor()
, который тоже использует тип события:
getEventColor: function (type) {
switch (type) {
case 'departure':
return '#8BC34A';
case 'return_flight':
return '#689F38';
case 'payment_deadline':
return '#D32F2F';
default:
return '#000000';
}
}
По-хорошему цвет бы определять через класс в описании стилей, но FullCalendar работает вот так. Думаю, в данном случае это не сильно страшно, поскольку данные конкретные значения цветов все равно остаются в рамках компонента календаря.
Поле url
позволяет задать ссылку, на которую пользователь перейдет при клике на событие. Если задать поле url
, всё событие будет анкор-тегом. В данном случае, для задания ссылок мы используем стандартную библиотеку vue-router
, то есть предполагается, что пути для payment_deadline и order где-то в нашем фронт-энде уже определены.
Кстати, также клик по календарному событию можно обрабатывать коллбэком, и это мы рассмотрим в третьей части (будем по клику показывать диалог редактирования).
Что касается поля extendedProps
, в него можно складывать любые дополнительные данные о событии, которые могут нам зачем-то понадобиться. Посмотрим на реализацию метода makeExtendedProps()
:
makeExtendedProps: function (calendar_event) {
var props;
if (calendar_event.type == 'payment_deadline') {
props = {
type: calendar_event.type,
parent_id: calendar_event.parent_id,
due: calendar_event.parent.due,
percent: calendar_event.parent.percent,
amount: calendar_event.parent.amount
};
} else {
props = {
type: calendar_event.type,
parent_id: calendar_event.parent_id,
tourists: calendar_event.parent.tourists,
tags: calendar_event.parent.tags,
hotel: calendar_event.parent.hotel,
operator: (calendar_event.parent.operator) ? calendar_event.parent.operator.name : '',
flight_date: (calendar_event.parent.flight_date) ? moment(calendar_event.parent.flight_date).format('DD.MM.YYYY') : 'Не указано',
return_date: (calendar_event.parent.return_date) ? moment(calendar_event.parent.return_date).format('DD.MM.YYYY') : 'Не указано'
};
}
return props;
}
В данном случае мы сохраняем в extendedProps
подробности о конкретных событиях, такие как сумма и срок для дедлайнов и такие данные как туристы, название отеля и даты вылета/возврата для событий вылета и возврата. По этим данным мы сможем построить дополнительную информацию о событиях, которая появится при наведении на них курсора.
Заметьте, что поле parent
, к которому мы то и дело обращаемся — это объект с данными родительской модели этого события, который в бэк-энде грузится eager-loading-ом (см. свойство $with
в первой части). Родителем, спрятанным за абстрактрым parent-ом, может быть модель PaymentDeadline
, или модель Order
.
Теперь займемся красивым блоком с дополнительной информацией о событии, который будет выскакивать при наведении на событие курсора. Для этого возьмем компонент Popover
из поставки Бутстрапа. Подцеплять поповер будем при рендере события, для чего мы завели отдельный метод onEventRender()
:
onEventRender: function (info) {
$(info.el).popover({
template: this.createPopoverTemplate(info.event),
html: true,
trigger: 'hover',
placement: 'auto',
container: 'body',
title: this.createPopoverTitle(info.event),
content: this.createPopoverContent(info.event)
});
}
Здесь я смалодушничал и обратился-таки к jQuery для вызова поповера. Как я понял, для того чтобы сделать это Vue-way, пришлось бы влезать в темплейт календарного события FullCalendar и там ковыряться, а мне пока не захотелось. Если бы захотелось, можно было бы сделать совсем круто — каждый поповер Vue-компонентом. Ну а так мы просто с помощью jQuery-селектора инициализируем поповер для календарного события во время его рендера.
Посмотрим на методы, которыми мы генерируем заголовок, темплейт и содержимое поповера:
createPopoverTitle: function (event) {
var title = '';
// добавим в заголовок имена туристов
if (event.extendedProps.tourists && event.extendedProps.tourists.length) {
let tourists = event.extendedProps.tourists;
title += '<span class="tourist">' + tourists[0].name + '</span>';
if (tourists.length > 1) {
title += ' (+' + (tourists.length-1) + ')';
}
} else {
// или напишем, что их пока нет
title += 'Туристы не указаны';
}
return title;
}
Для заголовка мы ничего особенного не делаем, просто выводим в него имена туристов в данном заказе, если они есть. Напомню, что в принципе мы можем запоминать в extendedProps
все, что угодно и из этого чего угодно строить заголовок.
Строим темплейт для поповера:
createPopoverTemplate: function (event) {
let type = event.extendedProps.type;
var note_class, note_icon;
// задает класс и иконку для содержимого поповера в зависимости от типа события
switch (type) {
case 'departure': {
note_class='note-departure';
note_icon = 'fas fa-plane-departure';
break;
}
case 'return_flight': {
note_class='note-return-flight';
// обратный самолет пустим в другую сторону :)
note_icon = 'fas fa-plane-departure fa-flip-horizontal';
break;
}
case 'payment_deadline': {
note_class='note-payment-deadline';
note_icon = 'far fa-money-bill-alt';
break;
}
}
// построим темплейт для содержимого поповера
var template = '<div class="note ' + note_class + ' popover">';
template += '<div class="arrow"></div>';
template += '<div class="note-icon"><i class="' + note_icon + '"></i></div>';
template += '<div class="note-content">';
template += '<h4 class="popover-header"></h4>';
template += '<div class="popover-body row"></div>';
template += '</div>';
template += '</div>';
return template;
}
Теперь содержимое поповера:
createPopoverContent: function (event) {
var type = event.extendedProps.type;
switch (type) {
case 'return_flight':
case 'departure':
return this.createFlightPopoverContent(event);
case 'payment_deadline':
return this.createDeadlinePopoverContent(event);
}
return '';
},
// для вылетов / возвратов
createFlightPopoverContent: function (event) {
var content = '';
var names;
content += '<div class="col-md-6">';
content += '<i class="fa fa-globe">' + " " + '</i>';
if (event.extendedProps.tags && event.extendedProps.tags.length) {
// выводим страны, которые у нас в массиве tags
names = event.extendedProps.tags.map(tag => tag.name).join(', ');
} else {
names = 'Страна не указана';
}
content += '<span>' + names + '</span>';
content += '</div>';
content += '<div class="col-md-6">';
content += '<i class="fa fa-building">' + " " + '</i>';
let hotel = (event.extendedProps.hotel) ? event.extendedProps.hotel : 'Отель не указан';
content += '<span>' + hotel + '</span>';
content += '</div>';
content += '<div class="col-md-12">';
content += '<i class="fa fa-briefcase">' + " " + '</i>';
let operator = (event.extendedProps.operator) ? event.extendedProps.operator : 'Оператор не указан';
content += '<span>' + operator + '</span>';
content += '</div>';
content += '<div class="col-md-12">';
content += '<i class="fa fa-clock">' + " " + '</i>';
let dates = event.extendedProps.flight_date + " — " + event.extendedProps.return_date;
content += '<span>' + dates + '</span>';
content += '</div>';
return content;
},
// для дедлайнов
createDeadlinePopoverContent: function (event) {
var content = '';
var names;
content += '<div class="col-md-6">';
content += '<i class="fa fa-clock">' + " " + '</i>';
content += '<span>До ' + moment(event.extendedProps.due).format('DD.MM.YYYY') + '</span>';
content += '</div>';
content += '<div class="col-md-6">';
content += '<i class="fa fa-ruble-sign">' + " " + '</i>';
content += '<span>' + event.extendedProps.amount + ' (' + event.extendedProps.percent + '%)' + '</span>';
content += '</div>';
return content;
},
Поскольку для добавления поповеров используется не Vue-way, приходится императивно добавлять HTML каждому из них таким способом, не самым наглядным и красивым. Но если бы мы хотели совсем упороться, можно было бы скажем сделать для содержимого поповеров свои Vue-компоненты, и создавать-добавлять их вот таким извращением: Creating Vue.js Component Instances Programmatically. Это тоже не Vue-way, но поскольку мы здесь работаем с ненативной для Vue библиотекой FullCalendar, мы в любом случае столкнемся с ограничениями библиотеки-интегратора fullcalendar-vue
(что она поддерживает — то будет Vue-way, а остальное вот так).
На этом всё. А в третьей части мы дополним наш фронт и бэк так, чтобы кнопка создания своих (то есть, пользовательских) ивентов — «Добавить событие» — заработала.