用Airtable、Gatsby和React构建交互式甘特图的详细指南

278 阅读7分钟

使用Gatsby,将不同的数据源整合到一个应用程序中是非常容易的。在这篇文章中,我们将开发一个任务管理工具,其数据是从Airtable获取的。我们将在前端使用React,采用混合渲染策略。

这是一个常见的场景:你想开发一个连接到电子表格应用程序或其他数据源的应用程序。在这篇文章中,我将向你展示如何用Gatsby框架实现这种类型的应用。在我们的示例应用程序中,任务将从Airtable工作区导入,并以甘特图的形式可视化。用户可以通过拖放来移动任务,之后所有的变化都将与Airtable同步。你可以把这个项目作为各种日程安排应用程序的模板。

A simple task table

你可以在我的Gatsby Cloud网站上现场试用这个结果。这个项目的src文件可以在我的GitHub仓库中找到。

设置项目

Gatsby是一个静态网站生成器。这意味着你用React编写你的应用程序,而Gatsby将你的代码翻译成浏览器可以理解的HTML文件。这个构建过程是在服务器端定期进行的,这与传统的网络应用不同,传统的网络应用的HTML代码首先是在用户的浏览器中的客户端组装的。因此,HTML文件在服务器上是静态可用的(因此被称为静态网站生成器),并可以在请求时直接发送到客户端。这减少了用户的应用程序的加载时间。

SitePoint的Gatsby教程提供了你用这个框架开发应用程序所需的所有信息。如果你想一步一步地开发我的示例应用程序,你应该按照下面的大纲开始。

首先,你应该下载并安装Node.js。你可以在控制台输入node -v ,检查它是否正确安装。Node的当前版本应该被显示出来。

node -v
> v14.16.0

有了Node,我们还得到了npm,即Node软件包管理器。通过这个工具,我们现在可以安装Gatsby CLI。

npm install -g gatsby-cli

我们准备使用Gatsby CLI创建一个新项目。我把它命名为 "gantt-chart-gatsby"。

gatsby new gantt-chart-gatsby

然后用命令cd gantt-chart-gatsby 进入项目文件夹,用命令gatsby develop 建立项目。现在你可以在浏览器中打开项目的索引页,http://localhost:8000。起初,你应该只看到Gatsby为我们准备的欢迎页面。

在下一步,你应该检查项目的src 文件夹。子文件夹src/pages 包含项目中各个页面的React组件。现在,你只需保留索引页的index.js 文件就足够了,因为在我们的示例应用程序中,我们只需要一个页面。你可以删除这个文件夹中的其他文件,除了404.js (如果有人输入了错误的地址,这个文件可能会有用)。

如果你用这段代码覆盖了index.js 中的现有代码,这是一个很好的起点。

import * as React from 'react'

const IndexPage = () => {
  return (
   <main>
      <title>Gantt Chart</title>
      <h1>Welcome to my Gatsby Gantt Chart</h1> 

    </main>
  )
}

export default IndexPage;

你可以在命令行上用命令gatsby develop ,再次建立项目,并在浏览器中打开索引页。现在你应该看到一个空页面,标题是 "欢迎来到我的盖茨比甘特图"。

用React构建前端

索引页的第一个版本

我们将把甘特图实现为一个可重用的React组件。在我在下面的章节中详细解释该组件的实现之前,我首先要展示它是如何被初始化并嵌入索引页的。所以我建议你在我们完成该组件的第一个版本之前,先不要使用gatsby develop 命令。(当我们准备好的时候,我会让你知道!)

在这个示例项目中,我使用了 "工作 "和 "资源 "的概念。工作是绘制在图表单元格中的任务,可以通过拖放来移动。资源包含可以移动任务的行的标签。这些标签可以是任务的名称,但在其他用例中也可以是执行任务的人、车辆或机器的名称。

工作和资源是作为属性传递给甘特图组件的。在将任务管理工具连接到Airtable之前,我们用一些JSON格式的硬编码测试数据填充列表。

import * as React from "react";
import {GanttChart} from "../GanttChart";
import "../styles/index.css";

let j = [
  {id: "j1", start: new Date("2021/6/1"), end: new Date("2021/6/4"), resource: "r1"},
  {id: "j2", start: new Date("2021/6/4"), end: new Date("2021/6/13"), resource: "r2"},
  {id: "j3", start: new Date("2021/6/13"), end: new Date("2021/6/21"), resource: "r3"},
];

let r = [{id:"r1", name: "Task 1"}, {id:"r2", name: "Task 2"}, {id:"r3", name: "Task 3"}, {id:"r4", name: "Task 4"}];

const IndexPage = () => {
  return (
    <main>
      <title>Gantt Chart</title>
      <h1>Welcome to my Gatsby Gantt Chart</h1> 
      <GanttChart jobs={j} resources={r}/>
    </main>
  )
};

export default IndexPage;

甘特图的CSS样式

在下一步,我们在styles 文件夹中创建一个新的index.css 文件。(如果该文件夹不存在,在项目的文件夹src 中创建一个新的文件夹styles )。下面的CSS设置控制甘特图的布局和外观。

body{
  font-family: Arial, Helvetica, sans-serif;
}

#gantt-container{
  display: grid;     
}

.gantt-row-resource{
  background-color:whitesmoke;
  color:rgba(0, 0, 0, 0.726);
  border:1px solid rgb(133, 129, 129);
  text-align: center;
  padding: 15px;
}

.gantt-row-period{
  background-color:whitesmoke;
  color:rgba(0, 0, 0, 0.726);
  border:1px solid rgb(133, 129, 129);
  text-align: center;

  display:grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(40px, 1fr);
}

.period{
  padding: 10px 0 10px 0;
}

.gantt-row-item{
  border: 1px solid rgb(214, 214, 214);
  padding: 10px 0 10px 0;
  position: relative;
  background-color:white;
}

.job{
  position: absolute;
  height:38px;
  top:5px;
  z-index: 100;
  background-color:rgb(167, 171, 245);
  cursor: pointer;
}

实现GanttChart 组件

现在我将更详细地解释GanttChart 组件的实现。首先,我们需要在src 文件夹中建立一个名为GanttChart.js 的文件。在本教程中,我使用一个简化版本的GanttChart ,只用于一个月(2021年6月)。带有起始月和结束月选择字段的扩展版本可以在GitHub上找到,名字是GanttChart_extended.js

图表表分三步建立,由函数initFirstRow,initSecondRowinitGanttRows 表示。

import React from 'react';

export class GanttChart extends React.Component {

    names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

    constructor(props) {

        super(props);       

        this.state = {
            dateFrom: new Date(2021,5,1),
            dateTo: new Date(2021,5,30),
        };
    }

    render(){

        let month = new Date(this.state.dateFrom.getFullYear(), this.state.dateFrom.getMonth(), 1);

        let grid_style = "100px 1fr";

        let firstRow = this.initFirstRow(month);
        let secondRow = this.initSecondRow(month);
        let ganttRows = this.initGanttRows(month);

        return (

            <div className="gantt-chart">
                <div id="gantt-container" style={{gridTemplateColumns : grid_style}}>
                    {firstRow}
                    {secondRow}
                    {ganttRows}
                </div>
            </div>
        );
     }


    initFirstRow(month){...}

    initSecondRow(month){...}

    initGanttRows(month){...}


    //helper functions:

    formatDate(d){ 
        return d.getFullYear()+"-"+this.zeroPad(d.getMonth()+1)+"-"+this.zeroPad(d.getDate());  
    }

    zeroPad(n){
        return n<10 ? "0"+n : n;
    }

    monthDiff(d1, d2) {
        let months;
        months = (d2.getFullYear() - d1.getFullYear()) * 12;
        months -= d1.getMonth();
        months += d2.getMonth();
        return months <= 0 ? 0 : months;
    }

    dayDiff(d1, d2){   
        let diffTime = Math.abs(d2 - d1);
        let diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 
        return diffDays;
    }

}

initFirstRow 函数中,图表表的第一行被生成。正如你在上图中看到的,第一行由两个网格单元组成。这些单元格被生成为div,然后作为子节点插入 "gantt-container "中(见上面的列表)。第二个div也包含当前月份的标签。

React要求所有属于枚举的元素有一个独特的 "key "属性。这有助于优化渲染性能。

 initFirstRow(month){

    let elements = []; let i = 0;

    elements.push(<div key={"fr"+(i++)} className="gantt-row-resource"></div>);

    elements.push(<div key={"fr"+(i++)} className="gantt-row-period"><div className="period">{this.names[month.getMonth()] + " " + month.getFullYear()}</div></div>);

    return elements;
 }

图表表的下一行是在initSecondRow 函数中生成的。我们再次使用相同的原则:为每个表格单元创建一个div。你必须确保这些div是正确嵌套的(该行的第二个div包含每月每一天的单独div),这样CSS网格设置(见index.css 文件)才会产生所需的布局。

initSecondRow(month){

    let elements = []; let i=0;

    //first div
    elements.push(<div key={"sr"+(i++)} style={{borderTop : 'none'}} className="gantt-row-resource"></div>);

    let days = [];

    let f_om = new Date(month); //first day of month
    let l_om = new Date(month.getFullYear(), month.getMonth()+1, 0); //last day of month

    let date = new Date(f_om);

    for(date; date <= l_om; date.setDate(date.getDate()+1)){

        days.push(<div key={"sr"+(i++)} style={{borderTop: 'none'}} className="gantt-row-period period">{date.getDate()}</div>);
    }

    //second div in the row with child divs for the individual days
    elements.push(<div key={"sr"+(i++)} style={{border: 'none'}} className="gantt-row-period">{days}</div>);

    return elements;

}

图表表的其余行是在initGanttRows 函数中生成的。它们包含绘制工作的网格单元。同样,渲染是逐行进行的:对于每一行,我们首先放置资源的名称,然后我们遍历每月的各个日子。每个网格单元都被初始化为特定日期和资源的ChartCell 组件。通过cell_jobs 列表,单个单元格被分配了需要绘制到其中的工作(通常这正好是一个工作)。

initGanttRows(month){

    let elements = []; let i=0;

    this.props.resources.forEach(resource => {

        elements.push(<div key={"gr"+(i++)} style={{borderTop : 'none'}} className="gantt-row-resource">{resource.name}</div>);

        let cells = [];

        let f_om = new Date(month);
        let l_om = new Date(month.getFullYear(), month.getMonth()+1, 0);

        let date = new Date(f_om);

        for(date; date <= l_om; date.setDate(date.getDate()+1)){

            let cell_jobs = this.props.jobs.filter((job) => job.resource == resource.id && job.start.getTime() == date.getTime());

            cells.push(<ChartCell key={"gr"+(i++)} resource={resource} date={new Date(date)} jobs={cell_jobs}/>);
        }

        elements.push(<div key={"gr"+(i++)} style={{border: 'none'}} className="gantt-row-period">{cells}</div>);

    });

    return elements;
}

现在,在GanttChart.js 的末尾为ChartCell 组件添加以下代码。该组件将图表的单个表格单元渲染成一个包含一个或多个工作的子元素的div。显示一个工作的HTML代码由getJobElement 函数提供。

class ChartCell extends React.Component {

    constructor(props) {

      super(props);

      this.state = {
        jobs: props.jobs
      }
    }

    render(){

      let jobElements = this.props.jobs.map((job) => this.getJobElement(job));

      return (
        <div 
            style={{borderTop: 'none', borderRight: 'none', backgroundColor: (this.props.date.getDay()==0 || this.props.date.getDay()==6) ? "whitesmoke" : "white" }} 
            className="gantt-row-item">
            {jobElements}
        </div>
      );
    }

    getJobElement(job){

        let d = this.dayDiff(job.start, job.end);

        //Example: a job with a duration of 2 days covers exactly two grid cells, so the width is 2*100% and we have to add up 2px for the width of the grid lines
        return (
        <div    style={{width: "calc("+(d*100)+"% + "+ d + "px)"}} 
                className="job" 
                id={job.id} 
                key={job.id}
        >

        </div>
        );
    }

    dayDiff(d1, d2){   
        let diffTime = Math.abs(d2 - d1);
        let diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 
        return diffDays;
    }
}

在这一点上,你可以使用gatsby develop 命令从根文件夹中建立项目。索引页上的硬编码工作应该在甘特图中可见。它们还不能被拖放,但我们以后会处理这个问题。

继续阅读:在SitePoint 上用Airtable、Gatsby和React构建交互式甘特图