Showcase

Resource Planning Board

Three Apex products over one editable schedule. Drag a bar on the timeline or retype a cell in the table, and the other two panels follow, including a capacity chart that is derived on every commit and stored nowhere.

Resource planning board: an editable Gantt, an editable grid and a derived capacity chart over one task modelOpen in new tab

Built with ApexGantt, ApexGrid, ApexCharts.js

A planning board is not a dashboard. A dashboard reads; this one writes. The schedule above can be changed from two places at once, by dragging a bar on the timeline or by retyping a cell in the table, and the third panel is recomputed from whatever the other two left behind.

That single difference, edits flowing back into the data rather than out of it, changes almost every decision in the wiring. The sales analytics dashboard is the read-only version of this problem and its central device, a re-entrancy guard, turns out to be unnecessary here. This page is about what replaces it.

Everything below was measured against apexgantt 3.18.1 and apex-grid 3.5.0 in a browser rather than read out of the type definitions.

Why can the Gantt not be the source of truth?

Because it will not give the tasks back.

There is no getTasks() on the instance. getState() exists, and it returns the UI state only: zoom, scroll position, collapsed rows, selection, sort, filter rules, column widths and order. Not one task.

Object.keys(gantt.getState())
// ['version','zoom','scroll','collapsed','selected',
//  'sort','filterRules','quickFilter','group','columnWidths','columnOrder']

So when a reader drags a bar, the new dates exist in exactly one place: the event payload. The handler either copies them somewhere durable or they are gone, and the next thing that re-renders the component will quietly put the bar back where it started.

That forces the shape of the whole application. One array is the truth, both components are views over it, and every edit is a write to the array followed by a repaint. The Gantt is never asked what it thinks the schedule is.

let tasks = [ /* the only copy that counts */ ]

function commit(label, mutate) {
  const before = structuredClone(tasks)
  mutate()
  undoStack.push({ label, before, after: structuredClone(tasks) })
  redoStack.length = 0
  repaint(label)
}

Why is there no re-entrancy guard?

The read-only dashboard needs one because setting a selection in code makes the component emit the same event a user click emits, so the first click ping-pongs. Write-back looks like it should be worse: two components, both writing to each other.

It is not, and the reason is worth measuring rather than assuming. Neither product's write API raises the event its own user gesture raises.

ActionWhat the Gantt emitsWhat the grid emits
User drags a bartaskDragged, then historyChangenothing
gantt.updateTask(id, {...})historyChange onlynothing
User edits a cellnothingcellValueChanging, then cellValueChanged
grid.data = rowsnothingnothing at all

taskDragged, taskResized and cellValueChanged are therefore user-only signals. A repaint cannot come back as an edit, so there is no loop to guard against and no applying flag anywhere in this app.

That is a property of these two libraries, measured on these two versions, not a general law. It is also the kind of thing to re-check rather than inherit: the map component in the other showcase does echo, which is exactly why that page needs the guard this one does not.

Which undo stack wins?

Both products ship one, and their defaults disagree.

Records by defaultOption
ApexGanttYeshistory: { enabled: false }
ApexGridNoediting: { history: { enabled: true } }

Left alone, you get an undo that works for half the edits: drag a bar and the Gantt can undo it, retype a cell and nothing can. Worse, a single commit in this app touches both components, so two independent stacks would record two entries for one user action and then disagree about which to pop first.

The deciding detail is that the Gantt's historyChange payload carries no task id:

{ kind: 'undo', canUndo: true, canRedo: true, undoSize: 2,
  redoSize: 1, topUndoLabel: 'Update task t2', timestamp: 1790702324564 }

A label, not an identity. Combined with the absence of getTasks(), an undo performed inside the Gantt is unresyncable: you know something changed, you cannot find out what, and you cannot read the result. So both built-in stacks are switched off and history lives over the model, where one entry means one user action and undoing is just swapping the array back.

One measured wrinkle. Disabling the Gantt's history does stop the recording, undoSize stays at 0 and canUndo() stays false, but historyChange still fires on every mutation with kind: 'record'. A toolbar wired to that event has to read canUndo off the payload rather than treat the event itself as proof that something was recorded.

Where do you reject a value the user typed?

Before it is written, and there is exactly one opportunity.

cellValueChanging.data is documented as a live reference to the row object inside grid.data, and it is: the grid writes the new value into that object in place. Since the app seeds grid.data from the model, the row the grid is holding and the row the model is holding are the same object, which is convenient until you want to refuse an edit.

// Measured: the row in grid.data IS the model's row object.
grid.data[0] === tasks[0]          // true
grid.data[0].progress = 99
tasks[0].progress                   // 99

By the time cellValueChanged fires, the value is already in the model. So validation belongs in cellValueChanging, which is cancellable:

grid.addEventListener('cellValueChanging', (e) => {
  const { key, newValue } = e.detail
  if (key === 'assignee' && !(newValue in TEAM)) {
    e.preventDefault()   // rolls the candidate back, editor stays open
  }
})

Type a name that is not on the team and the board says so instead of silently creating a fourth person with no capacity.

What does each product actually do here?

The division is by job, not by data. All three are looking at the same array.

ProductOwnsNever does
ApexGanttTime. Bars, dependencies, dragging and resizing. Its task list is cut to a single label column through columnConfig so it is not a second table.Hold the schedule. It is repainted from the model, never queried for it.
ApexGridTyping. The editable columns, validation, and the tabular read of the same tasks.Own its own history, or keep a private copy of a row.
ApexChartsDerivation. Person-days per week against a capacity line, recomputed on every commit.Store anything. Delete the chart and no information is lost.

The capacity line is the clearest statement of that last row. It is not a field on a task; it is CAPACITY, a constant, compared against a number the app adds up from dates at repaint time. Nothing persists it, so nothing can disagree with it.

What one edit costs

A repaint could rewrite every task on every commit. This one diffs first, so a drag produces a single call:

const painted = new Map(tasks.map((t) => [t.id, viewKey(t)]))

for (const t of tasks) {
  const key = viewKey(t)              // start|end|progress|assignee
  if (painted.get(t.id) === key) continue
  painted.set(t.id, key)
  gantt.updateTask(t.id, { /* ... */ })
}

Seeding painted from the series the Gantt was constructed with is what keeps the first repaint from being a storm of no-op writes. The grid is different: grid.data is reassigned wholesale every time, because the component diffs its own rows and, as measured above, the assignment raises no events.

One thing updateTask will not tolerate is an unknown id. It throws Task with ID <id> not found rather than ignoring it, so adding or removing a task means re-rendering the Gantt, not calling updateTask in a loop and hoping.

Installing the three

npm install apexgantt apex-grid apexcharts
import ApexGantt from 'apexgantt'
import 'apex-grid/define'      // registers <apex-grid>
import ApexCharts from 'apexcharts'

ApexGantt.setLicense('YOUR-KEY')

Only ApexGantt needs a key. The grid is a custom element, so it is registered by importing rather than constructed, and the chart is constructed against a DOM node in the usual way.

Which plans cover this?

ProductPlan
ApexCharts.jsCommunity and up, so covered by the under-$2M waiver
ApexGridCommunity and up, same
ApexGanttPremium and up

ApexGantt is the one that sets the floor for this application. Community is the entry plan and is free for organizations under $2M USD in annual revenue; at or above that it is a paid licence like the others. Nothing in the family is open source, and source published on GitHub is not an open licence. All three render in full without a key, watermarked, so the board above can be rebuilt on your own data before any of that matters. The pricing page has the matrix.

The read-only version of this problem, where the guard is the whole story

See the pieces running

Reference documentation

Frequently Asked Questions

Can a JavaScript Gantt chart and a data grid edit the same data?

Yes, provided neither of them owns it. Keep the tasks in your own array, treat both components as views, and route every edit through one commit function that writes the array and then repaints both. The alternative, letting each component hold its own copy and syncing them to each other, needs an adapter per pair and breaks as soon as a third view is added.

Does ApexGantt have a getTasks method?

No. Measured on apexgantt 3.18.1, the instance exposes getSelectedTasks() and getState(), and getState() returns UI state only: zoom, scroll, collapsed rows, selection, sort, filter rules and column widths. There is no way to read the full task list back, so after a drag the new dates exist only in the event payload. That is the reason the model has to live outside the component.

Do I need a re-entrancy guard when two components edit the same data?

Not with these two. Measured on apexgantt 3.18.1 and apex-grid 3.5.0, gantt.updateTask() emits historyChange and nothing else, and assigning grid.data emits no event at all, so a programmatic repaint cannot come back as a user edit. A read-only cross-filter dashboard does need one, because a map or chart selection set in code re-emits the selection event. Measure the write API before deciding, rather than carrying the guard over by habit.

Which undo stack should I use when both libraries have one?

Neither, if one user action touches both. ApexGantt records history by default and ApexGrid does not unless editing.history.enabled is set, so the out-of-the-box result is an undo that works for drags and not for typed edits. ApexGantt's historyChange payload also carries a label rather than a task id, so an undo performed inside the component cannot be resynced. Switch both off and keep one stack over your own model.

How do I reject an invalid value a user typed into the grid?

Call preventDefault() in cellValueChanging. It is the only chance you get: the event payload carries a live reference to the row object inside grid.data and the grid writes the new value into it in place, so by the time cellValueChanged fires the value is already in your model if the two share objects.

Which plans include ApexGantt, ApexGrid and ApexCharts together?

ApexCharts.js and ApexGrid are on every plan including Community, which is free for organizations under $2M USD in annual revenue. ApexGantt is Premium and above, so it sets the floor for this application. All three render in full without a licence key, watermarked, so the board can be rebuilt on your own data first. Redistributing them inside software you sell or host requires the OEM plan.

Related

Build an editable plan on your own data

ApexGantt is Premium and above; ApexCharts and ApexGrid are on every plan. Start with the installation guides.

Get started