Programmatic updates
Everything a user can do with the mouse, code can do with a method: updateTask moves, resizes or
re-progresses a task, addLink and removeLink change the dependencies, and setTasks replaces
the tasks. The first three return a promise of a result that says what happened, so a caller
knows more than the events can tell it. Each button calls one method, and the block under the chart
shows the call and its result.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// 2026-03-02 is a Monday. Discovery is finished and locked: `updateTask` leaves it alone, too.export const tasks: Task[] = [ { id: 'discovery', name: 'Discovery', start: '2026-03-02', end: '2026-03-06', progress: 100, locked: true, }, { id: 'design', name: 'Design', start: '2026-03-09', end: '2026-03-13', progress: 60 }, { id: 'build', name: 'Build', start: '2026-03-16', end: '2026-03-20', progress: 20 }, // No links yet: the buttons add and remove one. { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-18' }, { id: 'qa', name: 'QA', start: '2026-03-23', end: '2026-03-25' }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-26' },];
export const links: Link[] = [ { source: 'discovery', target: 'design' }, { source: 'design', target: 'build' }, { source: 'build', target: 'qa' }, { source: 'qa', target: 'launch' },];
/** The task that `setTasks` adds and removes in the example. */export const review: Task = { id: 'review', name: 'Review', start: '2026-03-26', end: '2026-03-27',};import type { GanttOptions } from '@ganttlane/dom';
// The freeze: no task may end after Friday 3 April 2026. Dates inside a `ChangeSet` are floating// wall-clock milliseconds (the time of the plan as if it were UTC), so this is the end of that// day, exclusive.const FREEZE = Date.parse('2026-04-04T00:00:00Z');
export const options = { // Runs for every change that is about to be committed: a drag, and `updateTask` (it sees // `reason: 'api'`). `false` cancels the change, and `updateTask` then resolves `cancelled`. onBeforeTaskChange: ({ changeSet }) => { return changeSet.changes.every((change) => change.after.endExclusive <= FREEZE); }, viewMode: 'week', scroll: { initial: 'first-task' }, // Smaller rows, so that every task is in view. layout: { rowHeight: 36, barHeight: 24 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { type Action, ACTIONS, changeText, issuesText } from './actions';import { links, tasks } from './data';import { options } from './options';
const IDLE = 'Press a button: the call and what it returned show above, the events below.';
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const root = document.createElement('div'); const toolbars = new Map<string, HTMLElement>(); const chart = document.createElement('div'); chart.style.height = '330px'; const result = document.createElement('pre'); result.className = 'updates-result'; result.setAttribute('aria-live', 'polite'); result.textContent = IDLE; const status = document.createElement('p'); status.textContent = 'Events: none yet.'; root.append(chart, result, status); container.append(root);
// The events that the last call caused, in the order they came. let events: string[] = []; const report = (line: string): void => { events.push(line); status.textContent = `Events: ${events.join('; ')}.`; };
const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // The events tell everybody else what a call did; the call's own result tells its caller. onTasksChange: (changeSet) => { report(changeText(changeSet)); }, onLinkCreate: (link) => { report(`onLinkCreate (${String(link.source)} → ${String(link.target)})`); }, onLinkDelete: (link) => { report(`onLinkDelete (${String(link.source)} → ${String(link.target)})`); }, onIssues: (payload) => { report(issuesText(payload)); }, });
async function run(action: Action): Promise<void> { events = []; status.textContent = 'Events: none.'; result.textContent = await action.run(gantt); }
// One row of buttons for each group of actions, above the chart. for (const action of ACTIONS) { let toolbar = toolbars.get(action.group); if (toolbar === undefined) { toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); toolbar.setAttribute('aria-label', action.group); const label = document.createElement('span'); label.className = 'updates-group'; label.textContent = action.group; toolbar.append(label); toolbars.set(action.group, toolbar); root.insertBefore(toolbar, chart); } const button = document.createElement('button'); button.type = 'button'; button.textContent = action.label; button.addEventListener( 'click', () => { void run(action); }, { signal: controller.signal }, ); toolbar.append(button); }
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { type Action, ACTIONS, changeText, issuesText } from './actions';import { links, tasks } from './data';import { options } from './options';
const IDLE = 'Press a button: the call and what it returned show above, the events below.';const GROUPS = [...new Set(ACTIONS.map((action) => action.group))];
export default function Example() { const chart = useRef<GanttHandle>(null); const [result, setResult] = useState(IDLE); // The events that the last call caused, in the order they came. const [events, setEvents] = useState<readonly string[]>([]); const report = (line: string): void => { setEvents((current) => [...current, line]); }; // `viewMode` is a controlled prop in React: `defaultViewMode` is where the chart starts, and // then the chart owns it. const { viewMode, ...rest } = options;
async function run(action: Action): Promise<void> { const handle = chart.current; if (!handle) return; setEvents([]); setResult(await action.run(handle)); }
return ( <> {GROUPS.map((group) => ( <div key={group} role="toolbar" aria-label={group}> <span className="updates-group">{group}</span> {ACTIONS.filter((action) => action.group === group).map((action) => ( <button key={action.label} type="button" onClick={() => void run(action)}> {action.label} </button> ))} </div> ))} <Gantt ref={chart} style={{ height: 330 }} // `tasks` and `links` are controlled props in React: these are where the chart starts, // and then it owns them, so `updateTask`, `addLink` and `setTasks` change its own copy. defaultTasks={tasks} defaultLinks={links} {...rest} defaultViewMode={viewMode} // The events tell everybody else what a call did; the call's own result tells its caller. onTasksChange={(changeSet) => { report(changeText(changeSet)); }} onLinkCreate={(link) => { report(`onLinkCreate (${String(link.source)} → ${String(link.target)})`); }} onLinkDelete={(link) => { report(`onLinkDelete (${String(link.source)} → ${String(link.target)})`); }} onIssues={(payload) => { report(issuesText(payload)); }} /> <pre className="updates-result" aria-live="polite"> {result} </pre> <p>{events.length === 0 ? 'Events: none.' : `Events: ${events.join('; ')}.`}</p> </> );}import type { Change, ChangeSet, Gantt, IssuesPayload, TaskPatch, UpdateTaskResult,} from '@ganttlane/dom';
import { review } from './data';
/** The methods of the chart that the buttons call: a `Gantt` of `createGantt`, or a React handle. */export type Chart = Pick< Gantt, 'updateTask' | 'addLink' | 'removeLink' | 'setTasks' | 'getIssues' | 'core'>;
/** What `addLink` and `removeLink` resolve to (their result types are not exported by name). */type LinkResult = Awaited<ReturnType<Gantt['addLink']>> | Awaited<ReturnType<Gantt['removeLink']>>;
/** A button: a call of the chart, and the text of what it returned. */export interface Action { /** The row of buttons it belongs to. */ readonly group: string; readonly label: string; /** Makes the call; resolves to the call and its result, as text. */ readonly run: (chart: Chart) => Promise<string>;}
const DAY = 24 * 60 * 60 * 1000;
/** A floating time as the day it falls on, `2026-03-16`. */const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
/** A task's dates the way a task is written: the first and the last day. */function span(patch: TaskPatch): string { const last = patch.endExclusive > patch.start ? patch.endExclusive - DAY : patch.start; return `${dayOf(patch.start)} to ${dayOf(last)}`;}
/** One change of a change set, as a line: who, why, the dates and the progress when it changed. */function changeLine({ id, cause, before, after }: Change): string { const progress = before.progress === after.progress ? '' : `, ${before.progress}% → ${after.progress}%`; return `${String(id)} (${cause}): ${span(before)} → ${span(after)}${progress}`;}
/** What a result holds, shortened to what is worth reading (a result has more than this). */function plain(result: UpdateTaskResult | LinkResult): unknown { const codes = (issues: readonly { code: string; path?: string | undefined }[] | undefined) => issues?.map(({ code, path }) => (path === undefined ? code : `${code} (${path})`)); switch (result.status) { case 'committed': return { status: result.status, // `changeSet.changes`: the task asked for, and what its links and children moved. changes: result.changeSet.changes.map(changeLine), }; case 'unchanged': return { status: result.status, issues: codes(result.issues) }; case 'rejected': return { status: result.status, reason: result.reason, issues: codes(result.issues) }; case 'cancelled': return { status: result.status, reason: result.reason }; case 'created': return { status: result.status, id: result.id, link: result.link }; case 'deleted': return { status: result.status, id: result.id }; case 'busy': return { status: result.status }; }}
/** The text of a call and what it resolved to. */async function call(text: string, result: Promise<UpdateTaskResult | LinkResult>): Promise<string> { return `await gantt.${text}\n→ ${JSON.stringify(plain(await result), null, 2)}`;}
/** What the buttons do, in the order they are shown. */export const ACTIONS: readonly Action[] = [ // ---- `updateTask`: a change from code, as a drag would make it ---- { group: 'updateTask', label: 'Move Build', // `start` alone moves the task and keeps its length; successors follow, as in a drag. run: (chart) => call( `updateTask('build', { start: '2026-03-23' })`, chart.updateTask('build', { start: '2026-03-23' }), ), }, { group: 'updateTask', label: 'Resize Build', // `end` alone moves the end edge. A date-only `end` is the last day, as in the data. run: (chart) => call( `updateTask('build', { end: '2026-03-26' })`, chart.updateTask('build', { end: '2026-03-26' }), ), }, { group: 'updateTask', label: 'Reschedule Docs', // Both: the task gets exactly these dates. run: (chart) => call( `updateTask('docs', { start: '2026-03-19', end: '2026-03-23' })`, chart.updateTask('docs', { start: '2026-03-19', end: '2026-03-23' }), ), }, { group: 'updateTask', label: 'Build to 75%', run: (chart) => call(`updateTask('build', { progress: 75 })`, chart.updateTask('build', { progress: 75 })), },
// ---- the answers that are not `committed` ---- { group: 'Not committed', label: 'Locked task', // `unchanged`: nothing to commit. A locked task is not changed from code either. run: (chart) => call( `updateTask('discovery', { progress: 50 })`, chart.updateTask('discovery', { progress: 50 }), ), }, { group: 'Not committed', label: 'End before start', // `rejected`: a value is unusable, and `issues` says which. run: (chart) => call( `updateTask('design', { end: '2026-03-01' })`, chart.updateTask('design', { end: '2026-03-01' }), ), }, { group: 'Not committed', label: 'Unknown task', // `rejected` again: the task has no bar to change. run: (chart) => call(`updateTask('nope', { progress: 10 })`, chart.updateTask('nope', { progress: 10 })), }, { group: 'Not committed', label: 'Past the freeze', // `cancelled`: `onBeforeTaskChange` said no (it runs for `updateTask` like for a drag). run: (chart) => call( `updateTask('qa', { start: '2026-05-04' })`, chart.updateTask('qa', { start: '2026-05-04' }), ), },
// ---- links and tasks ---- { group: 'Links', label: 'addLink Docs → QA', // `created`, or `rejected` with a reason: `duplicate`, `cycle`, `unknown-task`, `invalid`. run: (chart) => call( `addLink({ source: 'docs', target: 'qa' })`, chart.addLink({ source: 'docs', target: 'qa' }), ), }, { group: 'Links', label: 'addLink QA → Design', // Design leads to QA, so this one would close a cycle: `rejected`. run: (chart) => call( `addLink({ source: 'qa', target: 'design' })`, chart.addLink({ source: 'qa', target: 'design' }), ), }, { group: 'Links', label: 'removeLink Docs → QA', // A link has the id `source:type:target` unless it has an `id`. `deleted`, or `rejected`. run: (chart) => call(`removeLink('docs:FS:qa')`, chart.removeLink('docs:FS:qa')), }, { group: 'setTasks', label: 'Add Review', // `setTasks` answers nothing: it replaces the tasks, and the chart finds what changed by id. // Start from the chart's own tasks, which hold what the user dragged. run: (chart) => Promise.resolve(replaceTasks(chart, true)), }, { group: 'setTasks', label: 'Remove Review', run: (chart) => Promise.resolve(replaceTasks(chart, false)), },];
function replaceTasks(chart: Chart, add: boolean): string { const current = chart.core.getSnapshot().state.tasks; const without = current.filter((task) => task.id !== review.id); const next = add ? [...without, review] : without; chart.setTasks(next); const issues = chart.getIssues().data; return [ `gantt.setTasks(${add ? '[...tasks, review]' : 'tasks.filter((task) => task.id !== review.id)'})`, `→ undefined, and now there are ${next.length} tasks`, `gantt.getIssues().data → ${issues.length === 0 ? '[]' : issues.map(({ code }) => code).join(', ')}`, ].join('\n');}
/** A line for the status: what `onTasksChange` reported. */export function changeText({ reason, changes }: ChangeSet): string { return `onTasksChange (${reason}): ${changes.map((change) => String(change.id)).join(', ')}`;}
/** A line for the status: what `onIssues` reported. */export function issuesText({ source, issues }: IssuesPayload): string { return `onIssues (${source}): ${issues.map((issue) => issue.code).join(', ') || 'none'}`;}/* * Your own styles for what the example builds around the chart: the label of a row of buttons and * the block that shows a result. Colours come from the text colour of the page, so that they fit a * light and a dark page alike. */.updates-group { min-width: 6.5rem; opacity: 0.7; font-weight: 600;}
.updates-result { box-sizing: border-box; height: 11.5rem; margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; overflow: auto; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 0.375rem; background: color-mix(in srgb, currentColor 6%, transparent); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; line-height: 1.4; white-space: pre;}Files
data.ts: six tasks, four links, and thereviewtask thatsetTasksadds.options.ts:onBeforeTaskChange(the freeze), the view mode and the layout.actions.ts: the calls, one for each button, and the text of their results.styles.css: your own styles for the rows of buttons and the result.vanilla.tsandreact.tsx: the chart, the buttons and the events.
Options
| Option | Value | What it does |
|---|---|---|
onBeforeTaskChange |
a function | The freeze: cancels a change that makes a task end after 3 April, a drag or a call |
onTasksChange |
a function (in the example) | One ChangeSet for each committed call (reason: 'api') |
onLinkCreate |
a function (in the example) | addLink created a link |
onLinkDelete |
a function (in the example) | removeLink deleted a link |
onIssues |
a function (in the example) | A rejected value is reported here too (source: 'change-set') |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
layout.rowHeight, .barHeight |
36, 24 |
Smaller rows, so that every task is in view |
Methods
| Method | Resolves to |
|---|---|
updateTask(id, { start, end, progress }) |
committed (with the changeSet), unchanged, cancelled, busy or rejected (unknown-task, invalid-value) |
addLink(link) |
created, cancelled, busy or rejected (invalid, unknown-task, duplicate, cycle) |
removeLink(id) |
deleted, cancelled, busy or rejected (unknown-link) |
setTasks(tasks) |
nothing: it replaces the tasks and the chart finds the difference by id |
What to try
- Move Build.
startalone moves the task and keeps its length, and QA and Launch follow along their links: oneonTasksChange, with every task it moved. Press it again: the answer isunchanged, since Build is already there. - Resize changes the end edge (
endalone), Reschedule sets both ends, Build to 75% the progress. - Locked task. Discovery is
locked, soupdateTaskanswersunchangedand the issuelocked-task. - End before start and Unknown task are
rejected: the first with the issue and its path, the second because a task that is not there has no bar to change. - Past the freeze. The before-hook cancels the change, and the call resolves
cancelledwith the reasonhook. A drag that ends after 3 April is cancelled by the same hook. - Links.
addLinkDocs → QA iscreated; press it again and it isrejectedas aduplicate. QA → Design would close a cycle.removeLinkisdeleted, and the second timerejectedasunknown-link. - setTasks. Add Review and remove it again: the chart gets a new row and loses it.
Notes
- The same rules as the mouse. A call propagates through links and children like the same
drag and goes through
onBeforeTaskChange, which seesreason: 'api'. It does not snap to the view mode’s step and does not roll a bar onto a working day: the dates are exact. The editing flags andlinkabledo not apply to code (see Read-only), but a locked task does stay put. - A value that does not fit. A date off the
scheduling.granularityis aligned (and reported asmisaligned, or the call isrejectedwithalignInput: 'reject'). A date-onlyendis read the way the data is: the last day. busy. While a before-hook waits for an answer,updateTask,addLinkandremoveLinkresolvebusyand change nothing: see Before-hooks.setTasksstarts from the chart’s own tasks (gantt.core.getSnapshot().state.tasks), which hold what the user dragged. Passing your first array again would put those tasks back. In the uncontrolled mode used here, replacing the tasks also clears the undo history.- React. The buttons call the methods of the handle (
ref.current?.updateTask(…)).tasksandlinksare controlled props in React; the example passesdefaultTasksanddefaultLinks, so the chart owns its data and these calls change it. If your own state owns the tasks, change that state instead and let the new array reach the chart: see Controlled data.