dhtmlxGantt с PHP:Slim

В этом руководстве описаны все необходимые шаги для создания диаграммы Gantt на PHP с использованием Slim 4 Framework и RESTful API на стороне сервера.

Это руководство использует Slim Framework v4.x. Если вы работаете с более старой версией, обратитесь к руководству по Slim Framework v3.x.

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

В этом руководстве для маршрутизации будет использоваться Slim 4, а в качестве хранилища данных — MySQL. Операции CRUD будут реализованы с помощью PDO, что обеспечит гибкость и позволит использовать их с другими фреймворками.

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

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

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

Мы начнем с использования skeleton-приложения, предоставленного для Slim 4.

Для начала импортируйте проект и установите зависимости с помощью Composer:

php composer.phar create-project slim/slim-skeleton gantt-rest-php

Если Composer установлен глобально в вашей системе, используйте команду:

composer create-project slim/slim-skeleton gantt-rest-php

Далее убедитесь, что установка прошла успешно: перейдите в папку проекта и запустите веб-сервер:

cd gantt-rest-php
php -S 0.0.0.0:8080 -t public public/index.php

Затем откройте http://127.0.0.1:8080 в браузере, чтобы увидеть стандартную страницу приветствия Slim.

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

Следующий шаг — создать страницу, на которой будет отображаться диаграмма Gantt. Это делается в два простых этапа.

Создание представления

Начните с создания файла basic.html в папке app/templates. Этот файл будет содержать диаграмму Gantt и необходимую настройку для загрузки данных.

Вот полный код:

app/templates/basic.html

<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-type" content="text/html; charset=utf-8">
 
  <script src="https://cdn.dhtmlx.com/gantt/edge/dhtmlxgantt.js"></script>
  <link href="https://cdn.dhtmlx.com/gantt/edge/dhtmlxgantt.css" rel="stylesheet">
 
  <style type="text/css">
    html, body{
      height:100%;
      padding:0px;
      margin:0px;
      overflow: hidden;
    }
</style> </head> <body> <div id="gantt_here" style='width:100%; height:100%;'></div> <script type="text/javascript">
    gantt.init("gantt_here");
</script> </body> </html>

Это создаёт на странице пустую диаграмму Gantt. Пользователи могут создавать и изменять задачи и связи, но все изменения не сохраняются после обновления страницы.

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

Чтобы новая страница была доступна в браузере, добавьте следующий маршрут в app/routes.php:

app/routes.php

$app->get('/', function (Request $request, Response $response) {
$payload = file_get_contents(__DIR__.'/templates/basic.html');
$response->getBody()->write($payload);
return $response;
});

Перезапустите приложение командой:

command line

php -S 0.0.0.0:8080 -t public public/index.php

Теперь при переходе по адресу http://127.0.0.1:8080/ в браузере на странице отобразится диаграмма Gantt.

Шаг 3. Настройка базы данных

После отображения диаграммы Gantt следующим шагом будет создание базы данных и подключение её к приложению.

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

Базу данных можно создать с помощью любого удобного клиента MySQL (например, phpMyAdmin) или напрямую из консоли. Ниже приведён SQL-скрипт для создания простой базы данных с двумя таблицами.

CREATE DATABASE  IF NOT EXISTS `gantt`;
USE `gantt`;
 
CREATE TABLE `gantt_links` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `source` int(11) NOT NULL,
  `target` int(11) NOT NULL,
  `type` varchar(1) NOT NULL,
  PRIMARY KEY (`id`)
);
CREATE TABLE `gantt_tasks` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `text` varchar(255) NOT NULL,
  `start_date` datetime NOT NULL,
  `duration` int(11) NOT NULL,
  `progress` float NOT NULL,
  `parent` int(11) NOT NULL,
  PRIMARY KEY (`id`)
);

После создания базы данных таблицу gantt_tasks можно заполнить тестовыми данными. Используйте следующие SQL-команды:

INSERT INTO `gantt_tasks` VALUES ('1', 'Project #1', '2020-03-31 00:00:00', 
  '4', '0.8', '0');
INSERT INTO `gantt_tasks` VALUES ('2', 'Task #1', '2020-03-31 00:00:00', 
  '3', '0.5', '1');
INSERT INTO `gantt_tasks` VALUES ('3', 'Task #2', '2020-04-01 00:00:00', 
  '2', '0.7', '1');
INSERT INTO `gantt_tasks` VALUES ('4', 'Task #3', '2020-04-02 00:00:00', 
  '2', '0', '1');
INSERT INTO `gantt_tasks` VALUES ('5', 'Task #1.1', '2020-04-03 00:00:00', 
  '3', '0.34', '2');
INSERT INTO `gantt_tasks` VALUES ('6', 'Task #1.2', '2020-04-03 13:22:17', 
  '2', '0.5', '2');
INSERT INTO `gantt_tasks` VALUES ('7', 'Task #2.1', '2020-04-04 00:00:00',
  '3', '0.2', '3');
INSERT INTO `gantt_tasks` VALUES ('8', 'Task #2.2', '2020-04-05 00:00:00', 
  '2', '0.9', '3');

Более подробный пример приведён здесь.

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

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

Теперь необходимо настроить загрузку данных из базы данных. На стороне клиента данные будут запрашиваться с помощью метода gantt.load:

app/templates/basic.html

gantt.config.date_format = "%Y-%m-%d %H:%i:%s"; 
gantt.init("gantt_here");
gantt.load("/data");

Эта команда отправляет AJAX-запрос по указанному URL и ожидает, что ответ будет содержать данные Gantt в формате JSON.

Также обратите внимание на значение date_format. Оно сообщает Gantt о формате даты, используемом в источнике данных, что позволяет корректно парсить даты на клиенте.

Далее нужно добавить обработчик этого запроса на сервере. Откройте файл app/routes.php и добавьте новый маршрут:

app/routes.php

$app->get('/data',  'getGanttData');

После этого реализуйте функцию getGanttData. Чтобы index.php оставался чистым, весь код, связанный с Gantt, будет размещён в отдельном файле.

Создайте новый файл app/gantt.php и добавьте следующий код:

app/gantt.php

<?php
 
function getConnection()
{
    return new PDO("mysql:host=localhost;dbname=gantt", "root", "root", 
    // где "host" - имя хоста,
    // "dbname" - имя базы данных
    // "root" - имя пользователя
    // "root" - пароль
    [
      PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
      PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
    ]);
}
 
function getGanttData($request, $response, $args) {
  $db = getConnection();
  $result = [
    "data"=> [],
    "links"=> []
  ];
 
  foreach($db->query("SELECT * FROM gantt_tasks") as $row){
    $row["open"] = true;
    array_push($result["data"], $row);
  }
 
  foreach ($db->query("SELECT * FROM gantt_links") as $link){
    array_push($result["links"], $link);
  }
 
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
};

Затем подключите app/gantt.php в app/routes.php:

app/routes.php

<?php
declare(strict_types=1);
 
use App\Application\Actions\User\ListUsersAction;
use App\Application\Actions\User\ViewUserAction;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Interfaces\RouteCollectorProxyInterface as Group;
 
// Добавление dhtmlxGantt CRUD
require __DIR__ . "/gantt.php";
 
return function (App $app) {
    $app->get("/", function (Request $request, Response $response) {
        $payload = file_get_contents(__DIR__."/templates/basic.html");
        $response->getBody()->write($payload);
        return $response;
    });
 
    $app->get("/data",  "getGanttData");
 
    $app->group("/users", function (Group $group) {
        $group->get("", ListUsersAction::class);
        $group->get("/{id}", ViewUserAction::class);
    });
};

Краткое описание приведённого выше кода:

  • Маршрут для действия с данными определён в app/routes.php.
  • В обработчике маршрута все задачи и связи выбираются из базы данных и отправляются клиенту в формате JSON.
  • Для объектов задач добавляется свойство open, чтобы дерево задач было раскрыто по умолчанию.

На этом загрузка данных в Gantt реализована. Открыв http://127.0.0.1:8080/, вы увидите диаграмму Gantt, заполненную ранее добавленными тестовыми данными.

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

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

Откройте basic.html и добавьте следующие строки:

app/templates/basic.html

gantt.config.date_format = "%Y-%m-%d %H:%i:%s";
 
gantt.init("gantt_here");
gantt.load("/data");
 
var dp = new gantt.dataProcessor("/data");dp.init(gantt);dp.setTransactionMode("REST");

dataProcessor отслеживает действия пользователя, такие как добавление, изменение или удаление данных, и отправляет соответствующие AJAX-запросы на сервер. Он работает в режиме REST, используя разные HTTP-методы для различных действий. Полный список маршрутов доступен в документации.

Далее необходимо добавить эти маршруты в приложение и реализовать их логику. Сначала обновите app/routes.php:

app/routes.php

<?php
 
declare(strict_types=1);
 
use App\Application\Actions\User\ListUsersAction;
use App\Application\Actions\User\ViewUserAction;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
use Slim\Interfaces\RouteCollectorProxyInterface as Group;
 
// Добавление dhtmlxGantt CRUD
require __DIR__ . "/gantt.php";
 
return function (App $app) {
    $app->get("/", function (Request $request, Response $response) {
        $payload = file_get_contents(__DIR__."/templates/basic.html");
        $response->getBody()->write($payload);
        return $response;
    });
 
    $app->get("/data",  "getGanttData");
 
    $app->post("/data/task", "addTask");
    $app->put("/data/task/{id}", "updateTask");
    $app->delete("/data/task/{id}", "deleteTask");
 
    $app->post("/data/link", "addLink");
    $app->put("/data/link/{id}", "updateLink");
    $app->delete("/data/link/{id}", "deleteLink");
};

После добавления маршрутов можно реализовать соответствующие методы:

app/gantt.php

function getConnection()
{
    return new PDO("mysql:host=localhost;dbname=gantt", "root", "root", [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
    ]);
}
 
function getGanttData($request, $response, $args) {
  $db = getConnection();
  $result = [
    "data"=> [],
    "links"=> []
  ];
 
  foreach($db->query("SELECT * FROM gantt_tasks") as $row){
    $row["open"] = true;
    array_push($result["data"], $row);
  }
 
  foreach ($db->query("SELECT * FROM gantt_links") as $link){
    array_push($result["links"], $link);
  }
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
};
 
// получение задачи из данных запроса
function getTask($data)
{
  return [
    ":text" => $data["text"],
    ":start_date" => $data["start_date"],
    ":duration" => $data["duration"],
    ":progress" => isset($data["progress"]) ? $data["progress"] : 0,
    ":parent" => $data["parent"]
  ];
}
 
// получение связи из данных запроса
function getLink($data) {
  return [
    ":source" => $data["source"],
    ":target" => $data["target"],
    ":type" => $data["type"]
  ];
}
 
// создание новой задачи
function addTask($request, $response, $args) {
  $task = getTask($request->getParsedBody());
  $db = getConnection();
  $query = "INSERT INTO gantt_tasks(text, start_date, duration, progress, parent) ".
    "VALUES (:text,:start_date,:duration,:progress,:parent)";
  $db->prepare($query)->execute($task);
 
  $result = [
    "action"=>"inserted",
    "tid"=> $db->lastInsertId()
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
 
// обновление задачи
function updateTask($request, $response, $args) {
  $sid = $request->getAttribute("id");
  parse_str(file_get_contents("php://input"), $body);
  $task = [
      "text" => $body["text"],
      "start_date" => $body["start_date"],
      "duration" => $body["duration"],
      "progress" => $body["progress"],
      "parent" => $body["parent"]
  ];
  $db = getConnection();
  $query = "UPDATE gantt_tasks ".
    "SET text = :text, start_date = :start_date, duration = :duration,". 
      "progress = :progress, parent = :parent ".
    "WHERE id = :sid";
  $db->prepare($query)->execute(array_merge($task, [":sid"=>$sid]));
 
  if (isset($body["target"]) && $body["target"])
  updateOrder($sid, $body["target"], $db);
 
 
 
  $result = [
    "action"=>"updated"
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
 
// удаление задачи
function deleteTask($request, $response, $args) {
  $sid = $request->getAttribute("id");
  $db = getConnection();
  $query = "DELETE FROM gantt_tasks WHERE id = :sid";
 
  $db->prepare($query)->execute([":sid"=>$sid]);
  $result = [
    "action"=>"deleted"
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
 
// создание новой связи
function addLink($request, $response, $args) {
  $link = getLink($request->getParsedBody());
  $db = getConnection();
  $query = "INSERT INTO gantt_links(source, target, type) ".
    "VALUES (:source,:target,:type)";
  $db->prepare($query)->execute($link);
 
  $result = [
    "action"=>"inserted",
    "tid"=> $db->lastInsertId()
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
 
// обновление связи
function updateLink($request, $response, $args) {
  $sid = $request->getAttribute("id");
  parse_str(file_get_contents("php://input"), $body);
  $link = [
      "source" => $body["source"],
      "target" => $body["target"],
      "type" => $body["type"]
  ];
  $db = getConnection();
  $query = "UPDATE gantt_links SET ".
    "source = :source, target = :target, type = :type ".
    "WHERE id = :sid";
 
  $db->prepare($query)->execute(array_merge($link, [":sid"=>$sid]));
  $result = [
    "action"=>"updated"
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
 
// удаление связи
function deleteLink($request, $response, $args) {
  $sid = $request->getAttribute("id");
  $db = getConnection();
  $query = "DELETE FROM gantt_links WHERE id = :sid";
 
  $db->prepare($query)->execute([":sid"=>$sid]);
  $result = [
    "action"=>"deleted"
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}

Хотя код довольно объёмен, каждая функция проста: задачи и связи создаются, обновляются или удаляются. При добавлении возвращается идентификатор созданного элемента для клиента.

Обратите внимание, что связи между данными в базе здесь не обрабатываются: например, вложенные задачи или связанные связи не удаляются автоматически при удалении задачи. По умолчанию это поведение реализовано на клиенте — Gantt отправляет отдельные запросы на удаление для каждой дочерней задачи и связи.

Если вы хотите реализовать это на сервере, необходимо включить настройку cascade_delete.

Теперь приложение готово к запуску. Перейдите по адресу http://127.0.0.1:8080, чтобы увидеть полностью работоспособную диаграмму Gantt.

Сохранение порядка задач

На стороне клиента Gantt поддерживает изменение порядка задач с помощью drag-and-drop. Если вы используете эту функцию, порядок задач необходимо сохранять в базе данных. Общий обзор доступен здесь.

Далее необходимо добавить эту возможность в приложение.

Включение переупорядочивания задач на клиенте

Для начала пользователям необходимо предоставить возможность менять порядок задач напрямую в интерфейсе. Откройте файл basic.html и измените конфигурацию Gantt следующим образом:

app/templates/basic.html

gantt.config.order_branch = true;gantt.config.order_branch_free = true; 
gantt.init("gantt_here");

Далее эти изменения должны быть отражены на сервере. Порядок задач будет сохраняться в столбце с именем "sortorder". Пример определения таблицы gantt_tasks может выглядеть так:

CREATE TABLE `gantt_tasks` (
  `id` int(11) NOT NULL  AUTO_INCREMENT PRIMARY KEY,
  `text` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `start_date` datetime NOT NULL,
  `duration` int(11) NOT NULL,
  `progress` float NOT NULL DEFAULT 0,
  `parent` int(11) NOT NULL,
  `sortorder` int(11) NOT NULL );

Если таблица уже существует, можно добавить новый столбец следующим образом:

ALTER TABLE `gantt_tasks` ADD COLUMN `sortorder` int(11) NOT NULL;

После обновления базы данных необходимо соответствующим образом скорректировать CRUD-операции в app/gantt.php.

  1. Эндпоинт GET /data должен возвращать задачи, отсортированные по столбцу sortorder:

app/gantt.php

function getGanttData($request, $response, $args) {
  $db = getConnection();
  $result = [
    "data"=> [],
    "links"=> []
  ];
 
  foreach($db->query("SELECT * FROM gantt_tasks ORDER BY sortorder ASC") as $row){
    $row["open"] = true;
    array_push($result["data"], $row);
  }
 
  foreach ($db->query("SELECT * FROM gantt_links") as $link){
    array_push($result["links"], $link);
  }
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
};
  1. При добавлении новых задач им должен назначаться начальный sortorder:

app/gantt.php

function addTask($request, $response, $args) {
  $task = getTask($request->getParsedBody());
  $db = getConnection();
  $maxOrderQuery = "SELECT MAX(sortorder) AS maxOrder FROM gantt_tasks";
  $statement = $db->prepare($maxOrderQuery);
  $statement->execute();
 
  $maxOrder = $statement->fetchColumn();
  if(!$maxOrder)
    $maxOrder = 0;
 
  $task[":sortorder"] = $maxOrder + 1;
 
  $query = "INSERT INTO gantt_tasks(text, start_date, duration, progress, parent, sortorder) ".
    "VALUES (:text, :start_date, :duration, :progress, :parent, :sortorder)";
  $db->prepare($query)->execute($task);
 
  $result = [
    "action"=>"inserted",
    "tid"=> $db->lastInsertId()
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
  1. Наконец, при изменении порядка задач пользователем, порядок должен обновляться соответствующим образом как описано здесь:

app/gantt.php

// update a task
function updateTask($request, $response, $args) {
  $sid = $request->getAttribute("id");
  parse_str(file_get_contents("php://input"), $body);
  $task = [
      "text" => $body["text"],
      "start_date" => $body["start_date"],
      "duration" => $body["duration"],
      "progress" => $body["progress"],
      "parent" => $body["parent"],
      "sortorder" => $body["sortorder"]
  ];
  $db = getConnection();
  $query = "UPDATE gantt_tasks ".
    "SET text = :text, start_date = :start_date, duration = :duration,". 
      "progress = :progress, parent = :parent, sortorder = :sortorder ".
    "WHERE id = :sid";
  $db->prepare($query)->execute(array_merge($task, [":sid"=>$sid]));
 
  if(isset($body["target"]) && $body["target"])
    updateOrder($sid, $body["target"], $db);
 
 
  $result = [
    "action"=>"updated"
  ];
  $payload = json_encode($result);
 
  $response->getBody()->write($payload);
  return $response->withHeader("Content-Type", "application/json");
}
 
 
function updateOrder($taskId, $target, $db){
  $nextTask = false;
  $targetId = $target;
 
  if(strpos($target, "next:") === 0){
    $targetId = substr($target, strlen("next:"));
    $nextTask = true;
  }
 
  if($targetId == "null")
    return;
 
  $sql = "SELECT sortorder FROM gantt_tasks WHERE id = :id";
  $statement = $db->prepare($sql);
  $statement->execute([":id"=>$targetId]);
 
  $targetOrder = $statement->fetchColumn();
  if($nextTask)
      $targetOrder++;
 
  $sql = "UPDATE gantt_tasks SET sortorder = sortorder + 1 ".
    "WHERE sortorder >= :targetOrder";
  $statement = $db->prepare($sql);
  $statement->execute([":targetOrder"=>$targetOrder]);
 
  $sql = "UPDATE gantt_tasks SET sortorder = :targetOrder WHERE id = :taskId";
  $statement = $db->prepare($sql);
  $statement->execute([
    ":targetOrder"=>$targetOrder,
    ":taskId"=>$taskId
  ]);
}

Полный рабочий пример доступен на GitHub: https://github.com/DHTMLX/gantt-howto-php.

Использование dhtmlxConnector

Другой вариант реализации серверной части на PHP — использовать библиотеку dhtmlxConnector. Подробное руководство доступно здесь.

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

Сам Gantt не обеспечивает защиту от распространённых угроз безопасности, таких как SQL-инъекции, XSS или CSRF. Обеспечение безопасности приложения лежит на разработчиках серверной части. Подробнее читайте в этой статье.

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

Если после выполнения этих шагов Gantt не отображает задачи и связи, обратитесь к руководству по устранению неполадок: Устранение проблем интеграции с backend. В нём приведены методы для выявления и решения распространённых проблем.

Что дальше

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

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

К началу