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

Netspark.ru

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

В первой части статьи мы строили бэкенд на 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, а остальное вот так).

На этом всё. А в третьей части мы дополним наш фронт и бэк так, чтобы кнопка создания своих (то есть, пользовательских) ивентов — «Добавить событие» — заработала.

Первая часть статьи

Комментарии