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
Rebuilds this page in your assistant. It reads current API guidance from our MCP server rather than from its training data.

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. Where apexgantt 3.20.0 changes an answer, the section says so.

Why can the Gantt not be the source of truth?

Because, on 3.18.1, 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.

apexgantt 3.20.0 adds getTask(id) and getTasks(), which return copies of the stored tasks with edits included, plus getDependencies() for the links (a task's dependency field holds only the link it was loaded with), and an update() that does not pass series keeps them. The dates are no longer stranded in the payload, but the shape below still holds: the grid and the chart need the same array, and one undo stack has to cover edits made in either component.

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.

apexgantt 3.20.0 changes the second row: updateTask() also emits taskUpdate and taskUpdateSuccess, with source: 'api', unless you pass { silent: true }. This board listens only to taskDragged and taskResized, which still fire only for user edits, and its repaint passes silent anyway. An app that saves on taskUpdateSuccess has to pass silent on its repaints, or skip source === 'api'. From 3.20.0 a drag also fires taskUpdateSuccess (with source: 'drag'), so an app should save on that event or on taskDragged, not both.

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() on 3.18.1, an undo performed inside the Gantt is unresyncable: you know something changed, you cannot find out what, and you cannot read the result. From 3.20.0 getTasks() can read the result, but the payload still does not say which task changed, and undo and redo emit no taskUpdate. 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)
  // { silent: true } keeps the repaint out of taskUpdate and taskUpdateSuccess
  // from apexgantt 3.20.0; earlier versions ignore the third argument.
  gantt.updateTask(t.id, { /* ... */ }, { silent: true })
}

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. It is included in the Premium plan and above and has no free tier: the Community License, free for organizations under $2M USD in annual revenue, budget or funding, covers the other two and not ApexGantt. Nothing in the family is open source, and source published on GitHub is not an open licence. Without a key ApexGantt renders with a trial watermark for evaluation, as the other two do, 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?

From apexgantt 3.20.0, yes: getTask(id) and getTasks() return copies of the stored tasks with edits included. Measured on 3.18.1 there was none: the instance exposed getSelectedTasks() and getState(), and getState() returns UI state only (zoom, scroll, collapsed rows, selection, sort, filter rules and column widths), so after a drag the new dates existed only in the event payload. On this board the model still lives outside the component, because the grid and the chart read the same array and one undo stack has to cover edits made in either.

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

Not with these two, as long as the Gantt side listens to taskDragged and taskResized. 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. From apexgantt 3.20.0, updateTask() also emits taskUpdate and taskUpdateSuccess with source "api" unless you pass { silent: true }, so an app that saves on taskUpdateSuccess must pass silent on its repaints or skip that source. A read-only cross-filter dashboard does need a guard, 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, budget or funding. ApexGantt is included in the Premium plan and above and has no free tier, so it sets the floor for this application. Without a key it renders with a trial watermark for evaluation, as the other two do, 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