Crear diagramas de Gantt con JavaScript y Frappe Gantt

Si necesitas crear diagramas de Gantt con JavaScript puedes hacerlo mediante Frappe Gantt, la librería JavaScript para gráficos de Gantt.

En ésta publicación te enseño de forma práctica cómo trabajar con esta biblioteca JavaScript.

Ejercicio del tutorial de Frappe Gantt terminado. Haz click en la imagen para abrirlo en una ventana nueva.
Ejercicio del tutorial de Frappe Gantt terminado. Haz click en la imagen para abrirlo en una ventana nueva.

Si no te interesa la teoría y quieres ir directamente al ejercicio, ve directamente al tutorial práctico.

Hoy toca adentrarnos en el mundo de la planificación de recursos, aunque, por supuesto, lo vamos a hacer desde la perspectiva del desarrollo frontend.

Dado un período de tiempo, tendremos que representar las distintas tareas a desempeñar hasta alcanzar la fecha de entrega. 

Supongamos que nos han encargado la creación de un sistema de planificación de proyectos.

Parece un proyecto un tanto complejo, así que antes de lanzarnos de cabeza a programar una posible solución, buscaremos ejemplos que ya hayan resuelto esa necesidad.

Por poco que se indague, en seguida detectaremos un concepto que se repite una y otra vez. Diagrama de Gantt

Crear diagramas de Gantt con JavaScript y Frappe Gantt
Crear diagramas de Gantt con JavaScript y Frappe Gantt

Entre planificadores online tan conocidos, como Jira o Bold Workplanner, aparecen diagramas de Gantt representados de una forma u otra.

Los diagramas de Gantt son gráficos que permiten visualizar un conjunto de datos, y sus relaciones, a lo largo de una línea temporal.

Este tipo de esquema fue ideado por Henry Gantt a principios del siglo XX, con el propósito de crear un gráfico que representara de forma clara e intuitiva, la evolución de los procesos de producción industriales.

Desde entonces, estos gráficos se han convertido en una herramienta ampliamente utilizada en la gestión de proyectos de todo tipo.

Así pues, volviendo a nuestro encargo, parece que implementar uno de estos diagramas es la solución más acertada.

Ahora toca preguntarse, ¿Cómo se afronta técnicamente todo esto? De nuevo, debemos buscar si ya existen soluciones de terceros disponibles.

Y vaya que sí existen, hay todo un catálogo de librerías JavaScript que ofrecen la posibilidad de construir flujos de Gantt desde cero.

Sin embargo, muchos de ellos son soluciones «premium», con precios un tanto elevados.  **coff coff coff Bryntum**

Así que tras filtrar un poco, opté por usar Frappe Gantt

Al final del post te he dejado el listado completo de librerias js para generar Gantts.

Frappe Gantt al rescate para la creación de diagramas de Gantt

Frappe Gantt, es una librería JavaScript open source que, con unas pocas líneas de código, prepara un diagrama de Gantt, lo suficientemente versátil, como para adaptarlo a nuestras necesidades.

Esta herramienta, ha sido desarrollada por el equipo de la empresa Frappe.

Actualmente, su repositorio en Github tiene más de 3.7K estrellas, y se descarga una media de 8973 veces a la semana.

Para usar la librería es necesario instalarla mediante el comando NPM:

npm install frappe-gantt

El paquete «frappe-gantt» expone una única clase que se puede importar con la siguiente instrucción:

import Gantt from "frappe-gantt";

La clase Gantt es extremadamente sencilla de usar, tal y como veremos a continuación.

El constructor admite tres argumentos en el momento de instanciar el objeto. Siendo los dos primeros obligatorios y el tercero opcional.

Por orden de invocación, pasaremos estos parámetros:

  • Selector CSS: una cadena de texto en forma de selector CSS, para indicar qué elemento del DOM debe actuar como contenedor.
  • Tasks: Se trata de un array de objetos que describen cada una de las tareas a representar en el Gantt. Más adelante estudiaremos el DTO de este objeto tarea. 
  • Opciones de configuración: Un objeto opcional con una serie de claves y valores para ajustar ciertos parámetros. Estos son algunos ejemplos de las opciones que acepta la librería.
    • header_height: 50,
    • column_width: 30,
    • step: 24,
    •  view_modes: [‘Quarter Day’, ‘Half Day’, ‘Day’, ‘Week’, ‘Month’],
    • bar_height: 20,
    • bar_corner_radius: 3,
    • arrow_curve: 5,
    • padding: 18,
    • view_mode: ‘Day’,
    • date_format: ‘YYYY-MM-DD’,
    • language: ‘en’, // or ‘es’, ‘it’, ‘ru’, ‘ptBr’, ‘fr’, ‘tr’, ‘zh’, ‘de’, ‘hu’
    • custom_popup_html: null

Cada objeto «tarea» dentro del array «tasks» se debe ajustar a la siguiente estructura.

  • id:  Un identificador único. 
  • name: Una cadena de texto con el nombre de la taera. 
  • start: La fecha de inicio de la tarea representada como ‘YYYY-MM-DD’. 
  • end: La fecha de fin de la tarea representada como ‘YYYY-MM-DD’.  
  • progress: Un valor de 0 a 100 para representar el porcentaje completado de la tarea.
  • dependencies: Una cadena de texto con ids de otras tareas separados por comas. Cada tarea referenciada aquí será una dependencia directa.
  • custom_class: Una cadena de texto para incluir clases CSS adicionales.

A continuación te muestro un bloque de código implementando su API

const tasks = [
  {
    id: 'task-1',
    name: 'Task 1',
    start: '2016-10-01',
    end: '2016-10-05',
    progress: 80,
    dependencies: ''
  },
  {
    id: 'task-2',
    name: 'Task-2',
    start: '2016-10-05',
    end: '2016-10-15',
    progress: 60,
    dependencies: 'task-1'
  },
  {
    id: 'task-3',
    name: 'Task-3',
    start: '2016-10-15',
    end: '2016-10-30',
    progress: 20,
    dependencies: 'task-1, task-2'
  },
];
const configuration = {
  header_height: 50,
  column_width: 30,
  step: 24,
  view_modes: ['Quarter Day', 'Half Day', 'Day', 'Week', 'Month'],
  bar_height: 20,
  bar_corner_radius: 3,
  arrow_curve: 5,
  padding: 18,
  view_mode: 'Day',
  date_format: 'YYYY-MM-DD',
  language: 'en',
  custom_popup_html: null
};
const gantt = new Gantt('#gant-container',tasks,configuration);

Como puedes apreciar, con muy poco esfuerzo ya disponemos de un diagrama de Gantt funcional.

Una vez creado el objeto “gantt”, podemos interactuar con él a través de sus métodos y propiedades.

Curiosamente, la documentación no explica en ningún sitio qué métodos expone el objeto, no obstante, tras un rápido vistazo al código fuente, descubrimos información muy interesante.

A través de la propiedad “$svg” podemos acceder al DOM generado por la librería. Disponer de acceso a este nodo, nos abre la posibilidad de alterar a bajo nivel el renderizado del SVG.

gantt.$svg.classList.toggle("hide-labels");

La propiedad “options” por otra parte, también está expuesta, editar algunos de sus valores, nos va a permitir alterar, a posteriori, las opciones que definimos en el momento de instanciar la clase.

gantt.options.bar_height = 100;

También existen varios métodos que podemos llamar desde la variable instanciada.

  • Con “gantt.refresh(tasks)” forzamos una actualización del contenido de nuestro diagrama de Gantt.
  • Mediante “gantt.render()” actualizamos un renderizado. Resulta útil si hemos modificado parámetros de la propiedad options.
  • A través de “gantt.change_view_mode(viewMode)” podemos modificar el modo de visualización del diagrama programáticamente.
  • Llamando a «gantt.clear()” destruimos toda la información y DOM generado por la librería Frappe Gantt.

Llegados a este punto, podríamos dar por concluido el estudio de ésta herramienta, sin embargo, eso sería quedarse en la superficie.

Vamos a poner a prueba el potencial de la Frappe Gantt, tratando de implementarlo en un proyecto un tanto ambicioso.

Recuperando la situación planteada al inicio del artículo, vamos a construir un gestor de recursos proyectos aplicando lo que hemos aprendido. 

Y para que nuestro planificador sea verdaderamente funcional, vamos a tratar de agregar y expandir nuevas funcionalidades que Frappe no nos ofrece de base. 

Por lo pronto se me ocurren algunas ideas  interesantes:

  • Capacidad para editar, crear y eliminar tareas dinámicamente. 
  • Capacidad para filtrar tareas por nombre y fecha.
  • Añadir un selector de rangos de tiempo.
  • Opciones para ocultar o mostrar las dependencias entre tareas y sus nombres en el Gantt. 

Ahora sí, abre tu editor de código favorito y empecemos.

Crear un diagrama de Gantt para gestión de proyectos

Como ya viene siendo habitual, te dejo un enlace al ejercicio terminado, como refuerzo para seguir ésta guía.

https://github.com/Danivalldo/libreriasjs/tree/master/FrappeGantt

Crea un proyecto frontend nuevo, e instala la librería con NPM, así como otras dependencias.

npm install frappe-gantt @shoelace-style/shoelace dayjs litepicker uuid

Además de la librería Frappe Gantt, he instalado las siguientes dependencias.

  • shoelace: una librería de componentes web para facilitar la creación de modales y desplegables.
  • litepicker: Una micro librería para generar “datepickers”.
  • uuid: Módulo para crear identificadores únicos.
  • dayjs: Una librería JavaScript para el manejo de fechas. Si no la conoces, te recomiendo echar un vistazo al artículo donde repasé todas sus bondades.

Manejar fechas con JavaScript y DayJs

Genera un documento index.html, con una “div” que tenga la clase “.app”, e incluye en él una cabecera con un título, un contenedor principal vacío, y un pié de página.

<div class="app">
  <div class="header">
    <img src="./gantt_logo.svg" alt="" />
    <h1>Mi planificador</h1>
    <span class="powered-by"
      >by <a href="https://libreriasjs.com">libreriasjs.com</a></span
    >
  </div>
  <div class="main-content"></div>
  <div class="footer">
    Build with 🍕 using
    <a href="https://frappe.io/gantt" rel="nofollow" target="_blank"
      >Frappe Gantt</a
    >
  </div>
</div>

Dentro de “main-container» iremos añadiendo distintos elementos de la interfaz gráfica.

En la parte superior, incluiremos un “toolbar”, con los distintos elementos:

  • Botón para crear tareas nuevas.
  • Desplegable con opciones de visualización del Gantt.
  • Otro desplegable con modos de vista.
  • Un campos de texto para filtrar por nombre de tarea.
  • Selector de fechas para filtrar por rango de tiempo.
<div class="toolbar">
  <div>
    <sl-button id="create-task-btn" variant="primary"
      >Crear tarea</sl-button
    >
  </div>
  <div>
    <sl-dropdown variant="primary">
      <sl-button slot="trigger" variant="primary">Opciones</sl-button>
      <div class="bg-dropdown">
        <ul>
          <li>
            <sl-range
              variant="primary-50"
              id="colWidthInput"
              label="Ancho columnas"
              min="30"
              max="100"
            ></sl-range>
          </li>
          <li>
            <sl-range
              id="rowHeightInput"
              label="Alto filas"
              min="5"
              value="30"
              max="100"
            ></sl-range>
          </li>
          <li>
            <sl-checkbox id="hideLabelsInput"
              >Ocultar nombres</sl-checkbox
            >
          </li>
          <li>
            <sl-checkbox id="hideArrowsInput"
              >Ocultar flechas</sl-checkbox
            >
          </li>
        </ul>
      </div>
    </sl-dropdown>
  </div>
  <div>
    <sl-select
      id="select-view-mode"
      placeholder="Vista"
      variant="primary"
    >
      <sl-option value="Day">Dia</sl-option>
      <sl-option value="Week">Semana</sl-option>
      <sl-option value="Month">Mes</sl-option>
      <sl-option value="Year">Año</sl-option>
    </sl-select>
  </div>
  <div>
    <sl-input
      id="keyFilterInput"
      placeholder="Filtrar por nombre"
      clearable
    ></sl-input>
  </div>
  <div>
    <input
      type="text"
      id="date-filters-input"
      placeholder="Filtrar por fechas"
    />
  </div>
</div>

Más adelante conectaremos las funcionalidades con el DOM, de modo que asegúrate de respetar nombres de clases y atributos.

A continuación, incluye una “div” vacía que tenga como identificador “gantt-container”. 

Tal como su nombre sugiere, usaremos ese contenedor para construir el gráfico de Gantt en su interior.

<div id="gantt-container"></div>

Antes de cerrar el contenedor “main-container”, añadimos una “modal” con un formulario para crear, editar y borrar tareas.

<sl-dialog id="dialog" label="Dialog" class="dialog-overview">
  <form>
    <sl-input label="Nombre" name="name"></sl-input>
    <sl-range
      label="Progreso"
      name="progress"
      min="0"
      max="100"
      value="50"
    ></sl-range>
    <sl-select
      label="Seleccionar dependencias"
      name="dependencies"
      multiple
      clearable
    ></sl-select>
    <div>
      <span class="alert-message"
        >* Crear vínculos cíclicos puede provocar un malfuncionamiento
        de la aplicación</span
      >
    </div>
    <div class="footer-dialog">
      <sl-button id="createBtn" variant="primary" type="submit"
        >Create</sl-button
      >
      <sl-button
        id="updateBtn"
        variant="primary"
        class="d-none"
        type="submit"
        >Update</sl-button
      >
      <sl-button
        id="deleteBtn"
        variant="danger"
        class="d-none"
        type="button"
        >Delete</sl-button
      >
    </div>
  </form>
</sl-dialog>

Con esto, damos por cerrado el layout HTML.

Un código HTML sin estilo CSS es como una pizza sin pepperoni, te la puedes comer, pero es triste…

Vamos a remediar eso, incluyendo un archivo nuevo llamado “style.css”.

@import url("https://fonts.googleapis.com/css2?family=Open+Sans:wght@300&display=swap");
:root {
  --sl-font-sans: "Open Sans", sans-serif;
  --primaryColor600: #6060c6;
  --primaryColor500: rgb(132, 132, 216);
  --sl-color-primary-600: var(--primaryColor600);
  --sl-color-primary-500: var(--primaryColor500);
}
body {
  background-color: rgb(252, 255, 255);
  font-family: "Open Sans", sans-serif;
  margin: 0;
}
.app {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}
.main-content {
  flex: 1;
  padding: 15px;
}
.header {
  background-color: var(--primaryColor600);
  color: #fff;
  display: flex;
  align-items: flex-end;
  gap: 10px;
  padding: 20px 20px;
}
.alert-message {
  display: inline-block;
  background-color: rgb(255, 207, 207);
  color: rgb(165, 0, 0);
  padding: 10px;
  border-radius: 5px;
  font-size: 0.8rem;
}
.header h1 {
  margin: 0;
  font-size: 1.8rem;
}
.header img {
  display: block;
  width: 35px;
}
.header .powered-by {
  align-self: flex-end;
  padding-bottom: 2px;
  font-size: 0.8rem;
}
.header .powered-by a {
  color: #fff;
}
#planner-container {
  width: 100%;
}
#gantt-container {
  width: 100%;
  overflow-x: hidden;
}
.toolbar {
  display: flex;
  gap: 10px;
}
.gantt .grid .weekend-highlight {
  fill: var(--primaryColor600);
  opacity: 0.1;
  pointer-events: none;
}
.gantt.hide-labels .bar .bar-group text {
  visibility: hidden;
}
.gantt.hide-arrows .arrow {
  visibility: hidden;
}
ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
ul li {
  display: block;
}
#date-filters-input {
  flex: 1 1 auto;
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
  min-width: 0px;
  height: 100%;
  color: var(--sl-input-color);
  background: none;
  box-shadow: none;
  padding: 0px;
  margin: 0px;
  cursor: inherit;
  appearance: none;
  height: calc(
    var(--sl-input-height-medium) - var(--sl-input-border-width) * 2
  );
  padding: 0 var(--sl-input-spacing-medium);
  font-family: var(--sl-input-font-family);
  font-weight: var(--sl-input-font-weight);
  letter-spacing: var(--sl-input-letter-spacing);
  border: solid 1px #d4d4d8;
  border-radius: 5px;
}
sl-input {
  margin-bottom: 20px;
}
sl-range {
  margin-bottom: 20px;
}
.d-none {
  display: none;
}
.footer-dialog {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 20px 0 0 0;
}
.bar-wrapper.hidden {
  opacity: 0;
  visibility: hidden;
}
.bg-dropdown {
  background-color: #fff;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1);
}
.footer {
  background-color: var(--primaryColor600);
  color: #fff;
  padding: 10px;
  font-size: 0.75rem;
}
.footer a {
  color: #fff;
}
@media (max-width: 768px) {
  .toolbar {
    display: block;
  }
}

Puedes dedicarle unos minutos a analizar su contenido, y adaptarlo a tus necesidades.

Crear diagramas de Gantt con JavaScript y Frappe Gantt

Finalmente, llegamos a la creación del comportamiento, mediante JavaScript.

He dividido el programa en dos archivos, el script principal llamado “main.js” y un servicio en “./src/GanttService.js”.

Empezaré detallando el segundo. Se trata de una clase con el nombre “GanttService” que contiene la instancia de la librería Frappe Gantt, así como otros métodos y funciones de apoyo.

Las primeras líneas de código, cargarán tres dependencias.

import Gantt from "frappe-gantt";
import { createSVG } from "frappe-gantt/src/svg_utils";
import dayjs from "dayjs";

Seguidamente, declaramos dos constantes, la primera define las propiedades por defecto del Gantt y la segunda prepara una tarea “dummy”.

Frappé Gantt no permite tener generar diagramas sin tareas, de modo que es necesario usar “defaultEmptyTask” como tarea vacía si el usuario borra todas las tareas reales.

const defaultOptions = {
  language: "es",
  bar_height: 30,
  header_height: 50,
  column_width: 30,
};
const defaultEmptyTask = {
  id: "Empty task",
  name: "",
  start: "2023-04-19",
  end: "2023-04-21",
  progress: 0,
  dependencies: "",
  custom_class: "hidden",
  type: "workOrder",
};

Declaramos la clase “GanttService” con su constructor y la exportamos al final del archivo.

class GanttService {
  constructor(containerSelector, tasks, options) {
    this.currentAction = null;
    this.eventTriggers = {};
    this.displacedTask = null;
    this._onPointerDown = this.onPointerDown.bind(this);
    this._onPointerUp = this.onPointerUp.bind(this);
    this._onPointerLeave = this.onPointerLeave.bind(this);
    this._onClick = this.onClick.bind(this);
    this.gantt = new Gantt(containerSelector, tasks, {
      ...defaultOptions,
      ...options,
      on_date_change: (task, start, end) => {
        if (!this.displacedTask) {
          if (typeof this.eventTriggers["taskresized"] === "function") {
            this.eventTriggers["taskresized"]({
              taskId: task.id,
              start,
              end,
            });
          }
          return;
        }
        if (this.displacedTask.taskId === task.id) {
          const diffSecondsStart = dayjs(start).diff(task.start, "seconds");
          this.displacedTask.seconds = diffSecondsStart;
        }
        if (
          this.displacedTask.seconds !== 0 &&
          typeof this.eventTriggers["taskmoved"] === "function"
        ) {
          this.eventTriggers["taskmoved"]({
            taskId: task.id,
            seconds: this.displacedTask.seconds,
          });
        }
      },
    });
    this.makeGridWeekendHighlights();
    this.gantt.$svg.addEventListener("pointerdown", this._onPointerDown);
    this.gantt.$svg.addEventListener("pointerup", this._onPointerUp);
    this.gantt.$svg.addEventListener("pointerleave", this._onPointerLeave);
    this.gantt.$svg.addEventListener("dblclick", this._onClick);
  }
}
export default GanttService;

Ya en el constructor suceden muchas cosas, permíteme que detalle cada una de ellas brevemente.

  • currentAction es una propiedad para identificar qué acción está llevando a cabo el usuario.
  • eventTriggers es un objeto que nos permitirá escuchar eventos de programación personalizados en una capa de software superior. Ideal para conectar el servicio con el script principal.
  • displacedTask contendrá información de tareas que se hayan modificado su posición.

Las demás propiedades son referencias a funciones de “callback”, para poder llamar a “removeEventListener” y evitar problemas de memoria.

En el constructor también instanciamos un objeto nuevo de la librería Frappe Gantt y lo almacenamos en la propiedad “gantt”.

Como verás, se construye con las opciones por defecto, combinadas con opciones recibidas por parámetro.

También declaramos la función de callback “on_date_change”. 

Las instrucciones dentro de este callback, deciden si ejecutar las funciones asociadas a los eventos “taskresized” o “taskmoved”, pasando datos de la tarea que ha sido modificada.

Por último, llamamos al método “makeGridWeekendHighlights” definido mas adelante, y agregamos “listeners” a nivel de DOM, para un mayor control del la interacción.

Definimos los métodos “onPointerDown”, “onPointerUp”, “onPointerLeave” y “onClick”, estos controlarán las acciones de ratón realizadas sobre el DOM del Gantt.

Es necesario hacerlo de este modo para guardar información necesaria y ejecutar correctamente las funciones de callback asociadas a determinados eventos.

onPointerDown(e) {
  if (this.currentAction || !e.target) return;
  this.displacedTask = null;
  const taskEl = e.target.closest("[data-id]");
  if (!taskEl) {
    e.preventDefault();
    return e.stopPropagation();
  }
  const taskId = taskEl.dataset.id;
  if (e.target.classList.contains("handle")) {
    if (e.target.classList.contains("progress")) {
      this.currentAction = { action: "isDraggingProgress", taskId };
      return;
    }
    this.currentAction = { action: "isDraggingTaskSize", taskId };
    return;
  }
  this.currentAction = { action: "isMovingTask", taskId };
}
onPointerUp(e) {
  if (!this.currentAction) return;
  const taskEl = this.gantt.$svg.querySelector(
    `[data-id="${this.currentAction.taskId}"]`
  );
  switch (this.currentAction.action) {
    case "isDraggingProgress":
      const barContainer = taskEl.querySelector(".bar-group");
      const widthBar = Number(
        barContainer.querySelector(".bar").getAttribute("width")
      );
      const widthBarProgress = Number(
        barContainer.querySelector(".bar-progress").getAttribute("width")
      );
      const percentage = Math.floor((widthBarProgress / widthBar) * 100);
      if (typeof this.eventTriggers["changeprogress"] === "function") {
        this.eventTriggers["changeprogress"]({
          taskId: this.currentAction.taskId,
          progress: percentage,
        });
      }
      break;
    case "isDraggingTaskSize":
      break;
    case "isMovingTask":
      const actionTask = this.gantt.tasks.find(
        (task) => task.id === this.currentAction.taskId
      );
      if (!actionTask) return;
      this.displacedTask = {
        taskId: this.currentAction.taskId,
        seconds: 0,
      };
      break;
  }
  this.currentAction = null;
}
onPointerLeave(e) {}
onClick(e) {
  if (this.currentAction || !e.target) return;
  const taskEl = e.target.closest("[data-id]");
  if (!taskEl) {
    e.preventDefault();
    return e.stopPropagation();
  }
  if (e.target.classList.contains("handle")) {
    return;
  }
  if (typeof this.eventTriggers["clicktask"] === "function") {
    const taskId = taskEl.dataset.id;
    this.eventTriggers["clicktask"](taskId);
  }
}

Para permitir que una capa de software superior vincule eventos personalizados a funciones propias, habilitamos el método “on”.

A través de él guardamos las funciones en el objeto “eventTriggers” bajo un nombre de evento específico.

on(eventId, callback) {
  this.eventTriggers[eventId] = callback;
}

Expondremos otra función llamada “updateTasks” encargada de actualizar el Gantt con nuevas tareas.

updateTasks(tasks = []) {
  this.gantt.refresh(tasks.length > 0 ? tasks : [{ ...defaultEmptyTask }]);
  this.makeGridWeekendHighlights();
}

Escribimos algunos métodos «setters» para editar las propiedades de inicio del Gantt.

changeView(viewMode) {
  this.gantt.change_view_mode(viewMode);
  this.updateGantt();
}
updateHeight(barHeight) {
  this.gantt.options.bar_height = barHeight;
  this.updateGantt();
}
updateColumnWidth(columnWidth) {
  this.gantt.options.column_width = columnWidth;
  this.updateGantt();
}
updateHeaderHeight(headerHeight) {
  this.gantt.options.header_height = headerHeight;
  this.updateGantt();
}
updateLanguage(lang) {
  this.gantt.options.language = lang;
  this.updateGantt();
}
updateCustomPopup(customPopup) {
  this.gantt.options.custom_popup_html = customPopup;
  this.gantt.popup = undefined;
}
updateGantt() {
  this.gantt.render();
  this.makeGridWeekendHighlights();
}

Debido a que Frappe Gantt no resalta los fines de semana por defecto, también implementamos una función para tal propósito.

makeGridWeekendHighlights = () => {
  if (this.gantt.view_is("Day")) {
    const width = this.gantt.options.column_width;
    const height =
      (this.gantt.options.bar_height + this.gantt.options.padding) *
        this.gantt.tasks.length +
      this.gantt.options.header_height +
      this.gantt.options.padding / 2;
    let x = 0;
    for (let date of this.gantt.dates) {
      const isWeekend = date.getDay() == 0 || date.getDay() == 6;
      if (isWeekend) {
        createSVG("rect", {
          x,
          y: 0,
          width,
          height,
          class: "weekend-highlight",
          append_to: this.gantt.layers.grid,
        });
      }
      x += this.gantt.options.column_width;
    }
  }
};

Gracias a los métodos “toggleLabels” y “toogleArrows”, lograremos hacer funcional las acciones para ocultar y mostrar las flechas y nombres del Gantt que se encuentra en la “toolbar”.

toggleLabels() {
  this.gantt.$svg.classList.toggle("hide-labels");
}
toogleArrows() {
  this.gantt.$svg.classList.toggle("hide-arrows");
}

Finalizamos la clase GanttService con un método para destruir de forma segura el diagrama generado.

destroy() {
  this.currentAction = null;
  this.eventTriggers = {};
  this.gantt.$svg.removeEventListener("pointerdown", this._onPointerDown);
  this.gantt.$svg.removeEventListener("pointerup", this._onPointerUp);
  this.gantt.$svg.removeEventListener("pointerleave", this._onPointerLeave);
  this.gantt.$svg.removeEventListener("dblclick", this._onClick);
  this.gantt.clear();
}

Con este servicio estamos a un paso de terminar la aplicación. Ahora toca conectar la interfaz con el servicio.

Para ello, creamos el script “main.js” e importamos todas las dependencias.

import "@shoelace-style/shoelace/dist/themes/light.css";
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/split-panel/split-panel.js";
import "@shoelace-style/shoelace/dist/components/range/range.js";
import "@shoelace-style/shoelace/dist/components/dropdown/dropdown.js";
import "@shoelace-style/shoelace/dist/components/checkbox/checkbox.js";
import "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import "@shoelace-style/shoelace/dist/components/select/select.js";
import "@shoelace-style/shoelace/dist/components/option/option.js";
import Litepicker from "litepicker";
import "litepicker/dist/plugins/mobilefriendly";
import dayjs from "dayjs";
import GanttService from "./src/GanttService";
import { v4 as uuid } from "uuid";
import "./style.css";

Guardamos en variables un conjunto de referencias a distintos nodos del DOM.

const colWidthInput = document.querySelector("#colWidthInput");
const rowHeightInput = document.querySelector("#rowHeightInput");
const hideLabelsInput = document.querySelector("#hideLabelsInput");
const hideArrowsInput = document.querySelector("#hideArrowsInput");
const createTaskBtn = document.querySelector("#create-task-btn");
const dialog = document.querySelector("#dialog");
const keyFilterInput = document.querySelector("#keyFilterInput");
const viewModeSelect = document.querySelector("#select-view-mode");

Declaramos una variable “tasks” con algunas tareas predefinidas.

let tasks = [
  {
    id: "task-1",
    name: "Wireframes",
    start: dayjs().format("YYYY-MM-DDTHH:mmZ"),
    end: dayjs().add(5, "days").format("YYYY-MM-DDTHH:mmZ"),
    progress: 50,
    dependencies: "",
    type: "workOrder",
  },
  {
    id: "task-2",
    name: "Diseño",
    start: dayjs().add(1, "week").format("YYYY-MM-DDTHH:mmZ"),
    end: dayjs().add(1, "week").add(5, "days").format("YYYY-MM-DDTHH:mmZ"),
    progress: 50,
    dependencies: "task-1",
    type: "workOrder",
  },
  {
    id: "task-3",
    name: "Desarrollo",
    start: dayjs().add(2, "week").format("YYYY-MM-DDTHH:mmZ"),
    end: dayjs().add(2, "week").add(5, "days").format("YYYY-MM-DDTHH:mmZ"),
    progress: 50,
    dependencies: "task-2",
    type: "workOrder",
  },
];

Incluímos una variable filters para almacenar la información de los distintos filtros aplicados por el usuario.

const filters = {
  filterKey: "",
  dateStartFilter: undefined,
  dateEndFilter: undefined,
};

Generamos una instáncia de nuestro servicio GanttService bajo el nombre “ganttSrv”.

const ganttSrv = new GanttService("#gantt-container", tasks);

Con la ayuda de la librería Litepicker, montamos un “datepicker” en el elemento “date-filters-input” del DOM

const picker = new Litepicker({
  plugins: ["mobilefriendly"],
  element: document.getElementById("date-filters-input"),
  resetButton: true,
  singleMode: false,
});

Para que los filtros definidos en los campos de la barra superior se apliquen, es necesario declarar una función específica.

const applyFilters = () => {
  const dStartFilter = dayjs(filters.dateStartFilter);
  const dEndFilter = dayjs(filters.dateEndFilter);
  return tasks
    .filter((task) => task.name.includes(filters.filterKey))
    .filter((task) => {
      if (!filters.dateStartFilter || !filters.dateStartFilter) return true;
      const dStartTask = dayjs(task.start);
      const dEndTask = dayjs(task.end);
      return dStartFilter.isBefore(dEndTask) && dEndFilter.isAfter(dStartTask);
    });
};

ApplyFilters se encargará de filtrar los eventos que no cumplan con los valores de los campos filtro.

Por otra parte, con la función “updateSelectDependencies” nos aseguramos que las tareas que se generen o destruyan se incluyen o borren del listado de posibles dependencias.

const updateSelectDependencies = () => {
  const dependencySelect = dialog.querySelector(
    'sl-select[name="dependencies"]'
  );
  dependencySelect.innerHTML = "";
  for (let i = 0, j = tasks.length; i < j; i++) {
    const task = tasks[i];
    const option = document.createElement("sl-option");
    option.setAttribute("value", task.id);
    option.innerHTML = task.name;
    dependencySelect.appendChild(option);
  }
};

Mediante el método “on” vinculamos funciones de “callback” a distintos eventos. Por ejemplo cuando el usuario hace click en una tarea.

ganttSrv.on("clicktask", (taskId) => {
  const task = tasks.find((task) => task.id === taskId);
  dialog.setAttribute("label", `Editar ${task.name}`);
  const dependencySelect = dialog.querySelector(
    'sl-select[name="dependencies"]'
  );
  dependencySelect.value = task.dependencies.concat(" ");
  dialog.querySelector('sl-input[name="name"]').value = task.name;
  dialog.querySelector('sl-range[name="progress"]').value = task.progress;
  dialog.querySelector("#createBtn").classList.add("d-none");
  dialog.querySelector("#updateBtn").classList.remove("d-none");
  dialog.querySelector("#deleteBtn").classList.remove("d-none");
  dialog.querySelector("form").setAttribute("data-task-id", taskId);
  dialog.show();
});

Aprovechamos la información recibida por parámetro para mostrar el formulario y actualizar los campos.

Si un usuario modifica el tamaño de una tarea, lo capturamos con el evento “taskresized”

ganttSrv.on("taskresized", ({ taskId, start, end }) => {
  const ganttTask = tasks.find((ganttTask) => ganttTask.id === taskId);
  if (!ganttTask) return;
  ganttTask.start = start;
  ganttTask.end = end;
  ganttSrv.updateTasks(applyFilters());
});

O bien, si desplaza una tarea, lo “escuchamos” en el evento “taskmoved”.

ganttSrv.on("taskmoved", ({ taskId, seconds }) => {
  const ganttTask = tasks.find((ganttTask) => ganttTask.id === taskId);
  if (!ganttTask) return;
  ganttTask.start = dayjs(ganttTask.start)
    .add(seconds, "seconds")
    .format("YYYY-MM-DDTHH:mm:ssZ");
  ganttTask.end = dayjs(ganttTask.end)
    .add(seconds, "seconds")
    .format("YYYY-MM-DDTHH:mm:ssZ");
  ganttSrv.updateTasks(applyFilters());
});

Te animo a que analices detenidamente las lógicas descritas dentro de cada función de callback.

Seguimos asociando funciones a eventos, ahora es el turno del componente “datepicker”.

picker.on("selected", (date1, date2) => {
  filters.dateStartFilter = date1.dateInstance;
  filters.dateEndFilter = date2.dateInstance;
  ganttSrv.updateTasks(applyFilters());
});
picker.on("clear:selection", () => {
  filters.dateStartFilter = undefined;
  filters.dateEndFilter = undefined;
  ganttSrv.updateTasks(applyFilters());
});

Sincronizamos la visualización de las tareas del Gantt con los filtros de rango de tiempo.

Conectamos los eventos de cambio de los campos de la barra de tareas, con las acciones pertinentes mediante “addEventListeners” y callbacks.

keyFilterInput.addEventListener("sl-input", (e) => {
  filters.filterKey = e.target.value;
  ganttSrv.updateTasks(applyFilters());
});
colWidthInput.addEventListener("sl-input", (e) => {
  ganttSrv.updateColumnWidth(Number(e.currentTarget.value));
});
rowHeightInput.addEventListener("sl-input", (e) => {
  ganttSrv.updateHeight(Number(e.currentTarget.value));
});
hideLabelsInput.addEventListener("sl-change", (e) => {
  ganttSrv.toggleLabels();
});
hideArrowsInput.addEventListener("sl-change", (e) => {
  ganttSrv.toogleArrows();
});
viewModeSelect.addEventListener("sl-change", (e) => {
  const viewMode = e.target.value;
  ganttSrv.changeView(viewMode);
});

Hacemos lo propio con la modal, el envío del formulario dentro del mismo, y los botones de crear y eliminar.

dialog.addEventListener("sl-hide", (e) => {
  if (!e.target.classList.contains("dialog-overview")) return;
  dialog.querySelector("#createBtn").classList.remove("d-none");
  dialog.querySelector("#updateBtn").classList.add("d-none");
  dialog.querySelector("#deleteBtn").classList.add("d-none");
  dialog.querySelector("form").removeAttribute("data-task-id");
  updateSelectDependencies();
});
dialog.querySelector("form").addEventListener("submit", (e) => {
  e.preventDefault();
  const taskId = e.target.dataset.taskId;
  const taskName = e.target.querySelector('[name="name"]').value;
  const taskDependencies = e.target
    .querySelector('[name="dependencies"]')
    .value.join(",");
  const taskProgress = e.target.querySelector('[name="progress"]').value;
  if (taskId) {
    const ganttTask = tasks.find((ganttTask) => ganttTask.id === taskId);
    ganttTask.name = taskName;
    ganttTask.dependencies = taskDependencies;
    ganttTask.progress = taskProgress;
  } else {
    tasks = [
      ...tasks,
      {
        id: uuid(),
        name: taskName,
        start: dayjs().format("YYYY-MM-DDTHH:mmZ"),
        end: dayjs().add(5, "days").format("YYYY-MM-DDTHH:mmZ"),
        progress: taskProgress,
        dependencies: taskDependencies,
        type: "workOrder",
      },
    ];
  }
  ganttSrv.updateTasks(applyFilters());
  return dialog.hide();
});
dialog.querySelector("#deleteBtn").addEventListener("click", (e) => {
  const taskId = e.target.closest("[data-task-id]").dataset.taskId;
  tasks = tasks
    .filter((task) => task.id !== taskId)
    .map((task) => ({
      ...task,
      dependencies: task.dependencies
        .map((dep) => dep.trim())
        .filter((dep) => dep !== taskId)
        .join(","),
    }));
  ganttSrv.updateTasks(applyFilters());
  dialog.hide();
});
createTaskBtn.addEventListener("click", () => {
  dialog.setAttribute("label", "Create new task");
  const dependencySelect = dialog.querySelector(
    'sl-select[name="dependencies"]'
  );
  dependencySelect.value = "";
  dialog.querySelector('sl-input[name="name"]').value = "";
  dialog.show();
});

Finalmente, terminamos escuchando la primera carga de la aplicació, para setear las dependencias iniciales del selector, y refrescar el Gantt.

window.addEventListener("load", () => {
  updateSelectDependencies();
  ganttSrv.updateGantt();
});

Alternativas a Frappe Gantt

Enhorabuena por haber llegado hasta aquí, no era un ejercicio precisamente sencillo.

Con todo, Frappe Gantt sigue siendo una de las librerías más “sencillas” a la hora de generar diagramas de Gantt.

Por otra parte, si necesitas una librería JavaScript más versátil que te permita crear cualquier tipo de gráfico, no puedes perderte Echarts.

Echarts la libreria para generar cualquier tipo de gráfico web

Si despues de este vistazo, sigues queriendo explorar alternativas más potentes y material adicional, a continuación te he preparado una lista que puede ser de tu interés.

Hasta la próxima publicación, un saludo.

Deja un comentario