Inertia + React: Creating A Gantt Chart Solution For Projects And Tasks Enter a brief summary for this post

Inertia + React: Creating a Gantt Chart Solution for Projects and Tasks

With the unfortunate mistake of opting to use the Frappe Gantt chart package, I ran into as many issues with it to the point I completely refactored it out of one project altogether.

Never again will I use it, nor can I recommend the package to you either. The next best solution of course is to use one of the commercially available React packages. Pay the license fee and be happy and besides, you get support if you face problems.

I beg to differ and instead I installed the Gantt Task React package.

The Gantt Task React Package

There were a few other packages that were either open source or otherwise license free, but this one I decided upon. As it turned out it was the best choice because it was so easy to work with.

Let's look at the basic structure first.

import { Gantt } from 'gantt-task-react'; 
import "gantt-task-react/dist/index.css";

export default function GanttCharts({ can, tasks, route }) { 

return (
        <>
            <Gantt 
                tasks={ data.tasks } 
                viewMode={ "Day" }
                locale={ "en-GB" }
                onClick={ handleOnClickChange }
                onDateChange={ handleOnDateChange }
                onProgressChange={ handleOnProgressChange }
                TooltipContent={ ({ task }) => <CustomTooltip /> } 
            />
        </>
    );
};

There are three callbacks I implemented for what functionality I deemed necessary for this MVP [minimal viable product] project. I also did away with the annoying tooltip because I hate those.

The first step for me was to format the task data and the final iteration is what I ended up with, below. It's great how Inertia manages the form data state for you and one of the best things I like about Inertia when working with the Laravel framework.

const { data, setData, post, processing, errors, reset } = useForm({
        tasks: tasks.map(task => {
            const start = new Date(task.start);
            const end = new Date(task.end);
            const duration = durationInDays(start, end);

            return {
                ...task,
                start: new Date(task.start),
                end: new Date(task.end),
                projectStart: new Date(task.projectStart),
                projectEnd: new Date(task.projectEnd),
                name: task.progress === 100 ? `${task.name} (100%, ${duration} days)` : `${task.name} (${task.progress}%, ${duration} days)`,
            }
        }),
        _method: "put",
    }); 

What is going on here is the start and end dates of the task must be a date object. Because of there not being a tooltip or other information display mechanism, I've put on the task bar:

  • the task name
  • the progress, as a percentage
  • the task duration, in days

It tends to work reasonably well from a UX point of view.

One thing I really like about this React package is how the task can be disabled out of the box. I didn't have to script my own solution, I only had to massage the data on the back end to accommodate the package features.

Next up is what I have in a Laravel controller.

// ...
$gantt_rows->each(function($item, $key) use($project) 
        {
            $item->type = 'task';
            $item->projectStart = $project->start_date;
            $item->projectEnd = $project->end_date;
            $item->url = route('tasks.show', ['task' => $item->id]);

            /**
             * @note    prevent the task interactivity, when the progress 
             *          is 100% or otherwise complete
             */
            
            $item->isDisabled = false;
            if($item->progress === 100) 
            { 
                $item->isDisabled = true; 
            }

        });
// etc,...

If you do not include the property if not 100% then expect an issue on the front end. I also decided to include the task dashboard URL for completeness.

Staying with Laravel, I have the following below for updating the task data.

// ...
DB::beginTransaction();

            foreach($request->tasks as $task)
            {   
                $model = Task::find($task['id']);
                $model->start_date = \Carbon\Carbon::parse($task['start'])->toDateString('Y-m-d');
                $model->end_date = \Carbon\Carbon::parse($task['end'])->toDateString('Y-m-d');
                $model->progress = $task['progress'];

                /**
                 * @note    maximum progress reached, assume the task has been completed 
                 *          in full, by updating completed status and completed at date
                 */

                if($model->progress == 100 && is_null($model->completed_at))
                {
                    $model->completed = true;
                    $model->completed_at = today();
                }

                $model->save(); 
            }
        
            DB::commit();
// etc,...

Now, if the progress has been dragged to 100% for a task, on the back end it is saved as being completed. Once a task has been completed it is disabled in the Gantt charts.

In other words, it cannot be dragged to change the start and end dates. Likewise, the progress or the duration cannot be changed either.

The callbacks that allow the tasks to be dragged to change the start and end dates, the progress and duration now follow. The callbacks also account for the project start and end dates. Therefore, a task cannot be dragged earlier than or later than when a project begins or ends.

This is something I found more difficult to implement with the Frappe Gantt package.

const handleOnDateChange = (task, children) => { 
        const { progress, start, end, projectStart, projectEnd } = task; 

        /**
         * @note    exit if start and end dates are not the proper, expected type
         */

        if(!(start instanceof Date) || !(end instanceof Date)) { 
            return; 
        }

        /**
         * @note    there may be an "extra day" because the difference is rounded up, 
         *          so subtract a day to balance out
         */

        const duration = durationInDays(start, end) -1;

        if(start < new Date(projectStart)) { 

            setUpdatedTasks(
                updatedTasks.map(
                    t => {
                        if (t.id === task.id) {
                            const originalName = t.originalName || t.name.replace(/\s*\([\d]+%.*\)$/, "");

                            return { 
                                ...t,
                                originalName, 
                                start: originalStart, 
                                name: `${originalName} (${progress}%, ${duration} days)` 
                            };
                        }
                        return t;
                    }
                )
            );
        }

        if(end > new Date(projectEnd)) {
            setUpdatedTasks(
                updatedTasks.map(
                    t => {
                        if (t.id === task.id) {
                            const originalName = t.originalName || t.name.replace(/\s*\([\d]+%.*\)$/, "");

                            return { 
                                ...t,
                                originalName, 
                                end: originalEnd, 
                                name: `${originalName} (${progress}%, ${duration} days)` 
                            };
                        }
                        return t;
                    }
                )
            );
        }

        /**
         * @note    if nothing has changed, exit by returning
         */

        const oldTask = data.tasks.find(t => t.id === task.id);
        if(oldTask.start.getTime() === start.getTime() && oldTask.end.getTime() === end.getTime()) {
            return;
        } 

        const updatedTasks = data.tasks.map(t => {
            if(t.id === task.id) { 
                const originalName = t.originalName || t.name.replace(/\s*\([\d]+%.*\)$/, "");

                return { 
                    ...t, 
                    originalName,
                    start, 
                    end,
                    name: `${originalName} (${progress}%, ${duration} days)` 
                };
            }

            return t; 
        });

        setData("tasks", updatedTasks);
    };

That callback does the heavy work for maintaining the task information on the task bar and pretty much everything else other than the progress. The progress callback is next.

const handleOnProgressChange = (task) => { 
        const { progress, start, end } = task;

        const duration = durationInDays(start, end);

        const updatedTasks = data.tasks.map(t => {
            if(t.id === task.id) {
                const originalName = t.originalName || t.name.replace(/\s*\([\d]+%.*\)$/, "");

                return { 
                    ...t, 
                    originalName, 
                    progress, 
                    name: `${originalName} (${progress}%, ${duration} days)` 
                };
            }

            return t; 
        });

        setData("tasks", updatedTasks);
    };

If you wish to alter the colors of the task bars, handle sizes and progress cursor and so on you can do so with styles. I've just left things are with defaults.

const handleOnClickChange = (task) => {};

    const CustomTooltip = () => { 
        return null;
    };

That more or less concludes another post. The issues I ran into with the Frappe Gantt package were to be expected and I suspected that from the outset but used it anyway.

The issues were to do with the package itself which I put down to being a bug and I wasn't prepared to dissect the library to find the fault. It made more sense to strip back the <GanttCharts /> component and start again with another package.

Anyway, if you ever find yourself needing to implement charts for tasks or time-based data then use the Gantt Task React package. It is more up to date and better maintained in my experience.