dhtmlxScheduler с PHP: Slim 3

В этом руководстве рассматриваются основные шаги по созданию планировщика на PHP с использованием Slim 3 Framework и REST API на серверной стороне.

Это руководство использует устаревшую версию Slim Framework v3.x. Для самой новой версии см. руководство Slim Framework v4.x.

Также доступны руководства по интеграции с другими платформами и фреймворками:

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

В данном случае используется Slim 3 вместе с REST API на сервере и MySQL для хранения данных. Операции CRUD будут реализованы через PDO, что обеспечивает гибкость и возможность использования с другими фреймворками.

Вы можете ознакомиться с полной демо-версией на GitHub. Следуйте пошаговым инструкциям для создания этого приложения.

Полный исходный код доступен на GitHub.

Шаг 1. Инициализация проекта

Создание проекта

В качестве отправной точки используйте skeleton application для Slim 3.

Начните с создания приложения с помощью Composer:

$ composer create-project slim/slim-skeleton scheduler-slim-howto
$ cd scheduler-slim-howto/
$ composer require illuminate/database "~5.1"

Шаг 2. Добавление Scheduler на страницу

Далее добавьте планировщик на страницу. Это включает два простых шага.

Создание view

Создайте файл scheduler.phtml в папке templates:

templates/scheduler.phtml

<!doctype html>
<html>
  <head>
    <title> Getting started with dhtmlxScheduler</title>
      <meta charset="utf-8">
       <script src="https://cdn.dhtmlx.com/scheduler/edge/dhtmlxscheduler.js"></script>
       <link href="https://cdn.dhtmlx.com/scheduler/edge/dhtmlxscheduler.css" 
            rel="stylesheet" type="text/css" charset="utf-8">
        <style>
            html, body{
                margin:0px;
                padding:0px;
                height:100%;
                overflow:hidden;
            }
</style> </head> <body> <div id="scheduler_here" class="dhx_cal_container" style='width:100%; height:100%;'> <div class="dhx_cal_navline"> <div class="dhx_cal_prev_button">&nbsp;</div> <div class="dhx_cal_next_button">&nbsp;</div> <div class="dhx_cal_today_button"></div> <div class="dhx_cal_date"></div> <div class="dhx_cal_tab" name="day_tab"></div> <div class="dhx_cal_tab" name="week_tab"></div> <div class="dhx_cal_tab" name="month_tab"></div> </div> <div class="dhx_cal_header"></div> <div class="dhx_cal_data"></div> </div> <script>
            scheduler.config.xml_date="%Y-%m-%d %H:%i";
            scheduler.init('scheduler_here', new Date(2019,0,20), "week");
            scheduler.load("/events", "json");
 
            var dp = scheduler.createDataProcessor("/events");
            dp.setTransactionMode("REST"); // use to transfer data with REST
            dp.init(scheduler);
</script> </body> </html>

Настройка маршрутов

Когда новая страница готова, настройте маршрут в src/routes.php, чтобы получить к ней доступ через браузер:

src/routes.php

$app->get('/', function (Request $request, Response $response, array $args) {
    return $this->renderer->render($response, 'scheduler.phtml', $args);
});

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

Scheduler initialization

Шаг 3. Подготовка базы данных

На этом этапе планировщик пуст. Следующий шаг — создать базу данных и подключить её к приложению.

Создание базы данных

Вы можете создать базу данных через любой удобный MySQL-клиент или из консоли. Вот SQL для создания базы и таблицы событий календаря:

CREATE DATABASE  IF NOT EXISTS `scheduler_howto_php`;
USE `scheduler_howto_php`;
 
DROP TABLE IF EXISTS `events`;
CREATE TABLE `events` (
  `id` int(11) AUTO_INCREMENT,
  `start_date` datetime NOT NULL,
  `end_date` datetime NOT NULL,
  `text` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8;

Чтобы импортировать через консоль MySQL, сохраните вышеуказанный код в файл dump.sql и выполните:

$ mysql -uuser -ppass scheduler < mysql_dump.sql

Далее откройте src/settings.php, добавьте массив конфигурации базы данных и укажите свои учетные данные:

src/settings.php

'pdo' => [
    'engine' => 'mysql',
    'host' => 'localhost',
    'database' => 'scheduler_howto_php',
    'username' => 'user',
    'password' => 'pass',
    'charset' => 'utf8',
    'collation' => 'utf8_unicode_ci',
 
    'options' => [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => true,
    ],
]

Затем в src/dependencies.php добавьте экземпляр PDO в контейнер приложения:

src/dependencies.php

// Добавление нового экземпляра PDO в контейнер
$container['database'] = function($container) {
 
   $config = $container->get('settings')['pdo'];
   $dsn = "{$config['engine']}:host={$config['host']};dbname={$config['database']};
   charset={$config['charset']}";
   $username = $config['username'];
   $password = $config['password'];
 
   return new PDO($dsn, $username, $password, $config['options']);
};

Шаг 4. Загрузка данных

Планировщик уже настроен на вызов "/events" для получения событий. Теперь добавьте обработчик этого запроса, чтобы отдавать реальные данные.

Поскольку потребуется несколько обработчиков, группы маршрутов помогут их организовать.

Откройте src/routes.php и добавьте группу для "/events" с действием GET:

src/routes.php

$app->group('/events', function () {
    $this->get('', function (Request $request, Response $response, array $args) {
        $db = $this->database;
        $queryText = 'SELECT * FROM `events`';
 
        $query = $db->prepare($queryText);
        $query->execute();
        $result = $query->fetchAll();
 
        return $response->withJson($result);
    });
});

После добавления событий в базу они появятся в планировщике.

Динамическая загрузка

На этом этапе планировщик загружает все события сразу, что допустимо для небольших наборов данных. Однако, если приложение используется для планирования или бронирования и старые записи не удаляются, количество событий быстро возрастет, что приведет к большим объемам данных при каждой загрузке страницы.

Динамическая загрузка позволяет запрашивать только события, видимые в текущем диапазоне дат. Каждый раз при смене вида гридом, планировщик получает только соответствующие данные.

Для этого на клиенте установите опцию setLoadMode в "day", "week" или "month":

scheduler.config.xml_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2019, 0, 20), "week");
scheduler.setLoadMode("day");
scheduler.load("/events", "json");

На сервере обработайте фильтры по датам следующим образом:

src/routes.php

$app->group('/events', function () {
    $this->get('', function (Request $request, Response $response, array $args) {
        $db = $this->database;
        $queryText = 'SELECT * FROM `events`';
 
        $params = $request->getQueryParams();         $queryParams = [];  
        if (isset($params['from']) && isset($params['to'])) {            $queryText .= " WHERE `end_date`>=? AND `start_date` < ?;";            $queryParams = [$params['from'], $params['to']];        } 
        $query = $db->prepare($queryText);
        $query->execute($queryParams);        $result = $query->fetchAll();
 
        return $response->withJson($result);
    });
});

Шаг 5. Сохранение изменений

Реализация серверных обработчиков

Теперь планировщик может читать данные с сервера. Следующий шаг — реализовать сохранение изменений в базу данных.

Клиент работает в режиме REST, отправляя запросы POST, PUT и DELETE для действий с событиями. Подробнее о формате запросов и маршрутах, используемых планировщиком.

Определите контроллер для обработки этих действий, настройте маршруты и включите сохранение на клиенте.

Добавьте обработчик POST в src/routes.php для вставки новых событий:

src/routes.php

$this->post('', function (Request $request, Response $response, array $args) {
    $db = $this->database;
    $body = $request->getParsedBody();
 
    $queryText = 'INSERT INTO `events` SET
                `start_date`=?,
                `end_date`=?,
                `text`=?';
 
    $queryParams = [
        $body['start_date'],
        $body['end_date'],
        $body['text']
    ];
 
    $query = $db->prepare($queryText);
    $query->execute($queryParams);
 
    $result = [
        'tid' => $db->lastInsertId(),
        'action' => 'inserted'
    ];
 
    return $response->withJson($result);
});

При добавлении нового события сервер возвращает его ID в свойстве tid ответа. JSON-ответ может содержать дополнительные свойства, доступные на клиенте.

Аналогично, добавьте обработчик PUT для обновления событий:

$this->put('/{id}', function (Request $request, Response $response, array $args) {
    $db = $this->database;
    $id = $request->getAttribute('route')->getArgument('id');
    $body = $request->getParsedBody();
 
    $queryText = 'UPDATE `events` SET
            `start_date`=?,
            `end_date`=?,
            `text`=?
            WHERE `id`=?';
 
    $queryParams = [
        $body['start_date'],
        $body['end_date'],
        $body['text'],
        $id
    ];
 
    $query = $db->prepare($queryText);
    $query->execute($queryParams);
 
    $result = [
        'action' => 'updated'
    ];
 
    return $response->withJson($result);
});

И обработчик DELETE для удаления событий:

$this->delete('/{id}', function (Request $request, Response $response, array $args) {
    $db = $this->database;
    $id = $request->getAttribute('route')->getArgument('id');
    $queryText = 'DELETE FROM `events` WHERE `id`=? ;';
 
    $query = $db->prepare($queryText);
    $query->execute([$id]);
 
    $result = [
        'action' => 'deleted'
    ];
 
    return $response->withJson($result);
});

Включение сохранения данных на клиентской стороне

Далее настроим клиентскую часть для работы с только что созданным API:

templates/basic.phtml

scheduler.config.xml_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2019, 0, 20), "week");
scheduler.setLoadMode("day");
 
// загрузка данных с backend
scheduler.load("/events", "json");
 
// отправка изменений на backend
var dp = scheduler.createDataProcessor("/events"); dp.init(scheduler); 
// установка режима обмена данными
dp.setTransactionMode("REST");

После перезапуска приложения вы сможете создавать, удалять и изменять события в планировщике. Все изменения сохраняются и остаются после обновления страницы.

Scheduler CRUD

Повторяющиеся события

Чтобы включить функции повторения (например, "повторять событие ежедневно"), необходимо добавить соответствующее расширение на страницу планировщика:

...
<body>
    ...
    <script>
        scheduler.plugins({
            recurring: true         });
        scheduler.init('scheduler_here', new Date(2019,0,20), "week");
        ...
</script> </body>

Для хранения данных о повторениях в таблице "events" необходимы дополнительные столбцы. Вот SQL-запрос для создания таблицы с поддержкой повторяющихся событий:

CREATE DATABASE  IF NOT EXISTS `scheduler_howto_php`;
USE `scheduler_howto_php`;
 
DROP TABLE IF EXISTS `events`;
CREATE TABLE `events` (
  `id` int(11) AUTO_INCREMENT,
  `start_date` datetime NOT NULL,
  `end_date` datetime NOT NULL,
  `text` varchar(255) DEFAULT NULL,
  `event_pid` int(11) DEFAULT 0,
  `event_length` bigint(20) unsigned DEFAULT 0,
  `rec_type` varchar(25) DEFAULT '',
  PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8;

Либо вы можете обновить существующую таблицу events из предыдущего шага следующими командами:

ALTER TABLE `events` ADD COLUMN `event_pid` int(11) DEFAULT '0';
ALTER TABLE `events` ADD COLUMN `event_length` bigint(20) unsigned DEFAULT '0';
ALTER TABLE `events` ADD COLUMN `rec_type` varchar(25) DEFAULT '';

Обновление backend

Также необходимо обновить обработчики на сервере, как описано в этом разделе.

Начнем с маршрута POST: обновите SQL-запрос, чтобы добавить новые столбцы.

Кроме того, обработайте особый случай для повторяющихся событий: удаление отдельного экземпляра серии повторяющихся событий требует создания новой записи. Клиент вызовет действие insert для этого:

src/routes.php

$this->post('', function (Request $request, Response $response, array $args) {
    $db = $this->database;
    $body = $request->getParsedBody();
 
    $queryText = 'INSERT INTO `recurring_events` SET
                `start_date`=?,
                `end_date`=?,
                `text`=?,
                `event_pid`=?,                `event_length`=?,                `rec_type`=?';    $queryParams = [
        $body['start_date'],
        $body['end_date'],
        $body['text'],
        // recurring events columns
        $body['event_pid'] ? $body['event_pid'] : 0,        $body['event_length'] ? $body['event_length'] : 0,        $body['rec_type']    ];
 
    // удаление одного экземпляра из серии повторяющихся событий
    $resultAction = 'inserted';    if ($body['rec_type'] === "none") {        $resultAction = 'deleted';//!
    }
    /*
    конец обработки данных повторяющихся событий
    */
 
    $query = $db->prepare($queryText);
    $query->execute($queryParams);
 
    $result = [
        'tid' => $db->lastInsertId(),
        'action' => $resultAction
    ];
 
    return $response->withJson($result);
});

Обработчик PUT также необходимо обновить аналогичным образом. Дополнительно, при изменении серии повторяющихся событий все измененные экземпляры этой серии должны быть удалены:

src/routes.php

$this->put('/{id}', function (Request $request, Response $response, array $args) {
    $db = $this->database;
 
    $id = $request->getAttribute('route')->getArgument('id');
    $body = $request->getParsedBody();
 
    $queryText = 'UPDATE `recurring_events` SET
            `start_date`=?,
            `end_date`=?,
            `text`=?,
            `event_pid`=?,            `event_length`=?,            `rec_type`=?            WHERE `id`=?';
    $queryParams = [
        $body['start_date'],
        $body['end_date'],
        $body['text'],
 
        $body['event_pid'] ? $body['event_pid'] : 0,        $body['event_length'] ? $body['event_length'] : 0,        $body['rec_type'],//!
 
        $id
    ];
 
    if ($body['rec_type'] && $body['rec_type'] != 'none') {      // все измененные экземпляры должны быть удалены при обновлении серии повторяющихся событий
      // https://docs.dhtmlx.com/scheduler/server_integration.html#recurringevents
        $subQueryText = 'DELETE FROM `recurring_events` WHERE `event_pid`=? ;';
        $subQuery = $db->prepare($subQueryText);
        $subQuery->execute([$id]);
    }
 
    $query = $db->prepare($queryText);
    $query->execute($queryParams);
 
    $result = [
        'action' => 'updated'
    ];
 
    return $response->withJson($result);
});

Наконец, для действия DELETE требуется обработка двух особых случаев:

  • Если у удаляемого события заполнен event_pid, это означает, что пользователь удаляет измененный экземпляр серии повторяющихся событий. Вместо удаления записи из базы данных необходимо установить rec_type='none', чтобы планировщик пропустил это событие.

  • Если пользователь удаляет всю серию повторяющихся событий, необходимо также удалить все измененные экземпляры этой серии.

src/routes.php

$this->delete('/{id}', function (Request $request, Response $response, array $args) {
    $db = $this->database;
    $id = $request->getAttribute('route')->getArgument('id');
 
    // логика поддержки повторяющихся событий
    // https://docs.dhtmlx.com/scheduler/server_integration.html#recurringevents
    $subQueryText = 'SELECT * FROM `recurring_events` WHERE id=? LIMIT 1;';    $subQuery = $db->prepare($subQueryText);    $subQuery->execute([$id]);    $event = $subQuery->fetch(PDO::FETCH_ASSOC); 
    if ($event['event_pid']) {        // удаление измененного экземпляра из серии повторяющихся событий
        // Вместо удаления обновляем rec_type на 'none' для этого события
       $subQueryText='UPDATE `recurring_events` SET `rec_type`=\'none\' WHERE `id`=?;';
       $subQuery = $db->prepare($subQueryText);
       $subQuery->execute([$id]);
 
        $result = [
            'action' => 'deleted'
        ];
 
        return $response->withJson($result);
    }
 
    if ($event['rec_type'] && $event['rec_type'] != 'none') {//!
        // при удалении серии повторяющихся событий удаляются все измененные экземпляры
        $subQueryText = 'DELETE FROM `recurring_events` WHERE `event_pid`=? ;';
        $subQuery = $db->prepare($subQueryText);
        $subQuery->execute([$id]);
    }
 
    /*
     конец обработки данных повторяющихся событий
    */
    $queryText = 'DELETE FROM `recurring_events` WHERE `id`=? ;';
 
    $query = $db->prepare($queryText);
    $query->execute([$id]);
 
    $result = [
        'action' => 'deleted'
    ];
 
    return $response->withJson($result);
});

Разбор серии повторяющихся событий

Повторяющиеся события хранятся в базе данных как отдельные записи, но могут быть развернуты в отдельные экземпляры на клиентской стороне с помощью Scheduler.

Если вам требуется работать с отдельными датами событий на сервере, рассмотрите возможность использования PHP-библиотеки для разбора повторяющихся событий в dhtmlxScheduler.

Готовую к использованию библиотеку можно найти на GitHub.

Безопасность приложения

dhtmlxScheduler — это клиентское решение и не содержит встроенных средств безопасности, чтобы оставаться гибким. Поэтому только клиентская часть не обеспечивает надежную защиту.

Это означает, что ответственность за безопасность приложения лежит на backend-разработчиках. Основные моменты:

  • SQL-инъекции: В этом примере используются параметризованные SQL-запросы, что помогает защититься от атак через инъекции.

  • XSS-атаки: Клиентская часть не фильтрует пользовательский ввод перед отправкой на сервер и не очищает данные сервера перед отображением на странице. В этом примере не реализована фильтрация XSS, поэтому рекомендуется добавить защиту, если вы планируете использовать этот код в своем приложении.

Обработка ошибок

Если backend не может выполнить действие, клиент ожидает ответ со статусом "error", как описано здесь.

Один из способов реализовать это — добавить middleware, который оборачивает ваши обработчики в блок try-catch и отправляет сообщение об ошибке клиенту в случае возникновения исключения.

Вы можете определить этот middleware в src/routes.php:

src/routes.php

$schedulerApiMiddleware = function ($request, $response, $next) {
    try {
        $response = $next($request, $response);
    } catch (Exception $e) {
        // Сбросить ответ и отправить детали ошибки
        $response = new \Slim\Http\Response();
        return $response->withJson([
            'action' => 'error',
            'message' => $e->getMessage()
        ]);
    }
    return $response;
};

Затем подключите его к вашей группе маршрутов:

src/routes.php

$app->group('/events', function () {
   ...
})->add($schedulerApiMiddleware);

На клиентской стороне вы можете отлавливать эти ошибки с помощью события onAfterUpdate объекта dataProcessor:

dp.init(scheduler);
dp.attachEvent("onAfterUpdate", function(id, action, tid, response){
    if(action == "error"){
        // обработка ошибки
    }
});

Устранение неполадок

Если вы выполнили все шаги, но Scheduler по-прежнему не отображает события на странице, ознакомьтесь со статьей Устранение проблем с интеграцией Backend. В ней приведены рекомендации по поиску и устранению причин проблемы.

Что дальше

На этом этапе у вас есть полностью рабочий Scheduler. Полный исходный код доступен на GitHub — вы можете клонировать, скачать и адаптировать его под свои проекты.

Также вы можете изучить руководства по различным возможностям Scheduler или туториалы по интеграции Scheduler с другими backend-фреймворками.

Наверх