Edit session, Save and Cancel
Your state owns the tasks and the links. The chart never changes them: it reports every committed change and shows it as a draft until you pass the new tasks back. An edit session wraps a spell of editing between Edit, Save and Cancel:
- every drag is an
onTasksChange(changeSet), which you write into your tasks withpatchTasks; - Save closes the session (
edit.commit()) and sends the plan to your server; - Cancel rolls the session back (
edit.rollback()): the chart does not touch your data, it tells you what to undo withonEditRollback, and you write that into your state the way you wrote the drags.
Until you press Edit the chart is in view mode (editing: false): bars cannot be dragged and links
cannot be drawn. A checkbox makes the “server” refuse the save, to see the last saved plan come back.
pnpm add @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'spnpm add @ganttlane/react react react-dom # React: it exports patchTasks toonpm install @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'snpm install @ganttlane/react react react-dom # React: it exports patchTasks tooyarn add @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'syarn add @ganttlane/react react react-dom # React: it exports patchTasks toobun add @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'sbun add @ganttlane/react react react-dom # React: it exports patchTasks tooimport type { Link, Task } from '@ganttlane/core';
// The plan as the server last saved it. Dependent tasks keep following their predecessors, so one// drag of Design moves Build and QA as well.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 60 }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20' }, { id: 'qa', name: 'QA', start: '2026-03-23', duration: 5 },];
export const links: Link[] = [ { source: 'design', target: 'build' }, { source: 'build', target: 'qa' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Your state owns the tasks and the links (<Gantt>: the `tasks` and `links` props). dataMode: 'controlled', // View mode: the chart cannot be edited from the UI (no drag, no links drawn). The example turns // it on between Edit and Save or Cancel. Your own calls (`updateTask`, `setTasks`) still work. editing: false, viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { patchTasks } from '@ganttlane/core';import { type ChangeSet, createGantt, type Gantt, type Link, type Task } from '@ganttlane/dom';
import { links as initialLinks, tasks as initialTasks } from './data';import { linkId, restoreLinks, savePlan } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '360px'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
const addButton = (label: string, onClick: () => void): HTMLButtonElement => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal: controller.signal }); toolbar.append(button); return button; }; const edit = addButton('Edit', () => { gantt.edit.begin(); // starts a session: the chart remembers the data it has now showEditing(true); status.textContent = 'Drag the bars, then Save or Cancel.'; }); const save = addButton('Save', () => void saveSession()); const cancel = addButton('Cancel', () => { gantt.edit.rollback(); // `onEditRollback` puts your data back showEditing(false); status.textContent = 'Cancelled: the plan is as it was.'; }); const refuse = document.createElement('input'); refuse.type = 'checkbox'; const refuseLabel = document.createElement('label'); refuseLabel.append(refuse, ' The server refuses the save'); toolbar.append(refuseLabel);
// Your own state: what the server has, and what you hold now. let saved = { tasks: initialTasks, links: initialLinks }; let tasks: Task[] = saved.tasks; let links: Link[] = saved.links;
// Write a change set (a drag, or the inverse of a cancelled session) into your own tasks. // `patchTasks` keeps the shape of every task: ISO strings stay ISO strings, durations stay durations. const apply = (changes: ChangeSet): void => { const { model } = gantt.core.getSnapshot().state.derived; tasks = patchTasks(tasks, changes, model); gantt.setTasks(tasks); };
// The handlers read `gantt`, which they are passed to: give them a return type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, onTasksChange: apply, // Links are yours the same way: the chart reports the one drawn or removed. onLinkCreate: (link): void => { links = [...links, link]; gantt.setLinks(links); }, onLinkDelete: (record): void => { links = links.filter((link) => linkId(link) !== record.id); gantt.setLinks(links); }, // A cancelled session: what to undo, which you write the way you wrote the drags. onEditRollback: (changes): void => { apply(changes); links = restoreLinks(links, changes.links); gantt.setLinks(links); }, // Save is only useful when the session has changes. onDirtyChange: ({ isDirty }): void => { save.disabled = !isDirty; }, });
/** Edit mode on or off: the buttons, and whether the chart can be edited from the UI. */ function showEditing(editing: boolean): void { gantt.setOptions({ editing }); edit.hidden = editing; save.hidden = !editing; cancel.hidden = !editing; save.disabled = !gantt.edit.isDirty; }
async function saveSession(): Promise<void> { // Closes the session. Anything but 'committed' ('no-session', 'busy') committed nothing. if (gantt.edit.commit().status !== 'committed') return; status.textContent = 'Saving…'; save.disabled = true; cancel.disabled = true; try { await savePlan(refuse.checked); saved = { tasks, links }; status.textContent = 'Saved.'; } catch { // The save failed: show the last saved plan. tasks = saved.tasks; links = saved.links; gantt.setTasks(tasks); gantt.setLinks(links); status.textContent = 'The server refused the plan: the last saved one is back.'; } cancel.disabled = false; showEditing(false); }
showEditing(false); status.textContent = 'Press Edit, drag the bars, then Save or Cancel.';
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { type ChangeSet, type Link, patchTasks, type Task, useGantt, useGanttState,} from '@ganttlane/react';import { useState } from 'react';
import { links as initialLinks, tasks as initialTasks } from './data';import { restoreLinks, savePlan } from './helpers';import { options } from './options';
export default function Example() { // Your own state: what you hold now, and what the server has. const [tasks, setTasks] = useState<readonly Task[]>(initialTasks); const [links, setLinks] = useState<readonly Link[]>(initialLinks); const [saved, setSaved] = useState<{ tasks: readonly Task[]; links: readonly Link[] }>({ tasks: initialTasks, links: initialLinks, }); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [refuse, setRefuse] = useState(false); const [status, setStatus] = useState('Press Edit, drag the bars, then Save or Cancel.');
// Write a change set (a drag, or the inverse of a cancelled session) into your own tasks. // `patchTasks` keeps the shape of every task: ISO strings stay ISO strings, durations stay durations. const apply = (changes: ChangeSet): void => { const { model } = gantt.core.getSnapshot().state.derived; setTasks((current) => patchTasks(current, changes, model)); };
const gantt = useGantt({ ...options, tasks, links, editing, // false is view mode: the UI cannot drag or draw (your own calls still work) onTasksChange: apply, onLinksChange: setLinks, // a link drawn or removed: your links with the change applied // A cancelled session: what to undo, which you write the way you wrote the drags. onEditRollback: (changes) => { apply(changes); setLinks((current) => restoreLinks(current, changes.links)); }, });
// Destructured, which keeps `react-hooks/refs` quiet about the ref callback. const { setContainer, slots } = gantt;
// Re-render when the session gains or loses changes: Save is only useful when there are some. const dirty = useGanttState(gantt, () => gantt.edit.isDirty);
const save = async (): Promise<void> => { // Closes the session. Anything but 'committed' ('no-session', 'busy') committed nothing. if (gantt.edit.commit().status !== 'committed') return; setSaving(true); setStatus('Saving…'); try { await savePlan(refuse); setSaved({ tasks, links }); setStatus('Saved.'); } catch { // The save failed: show the last saved plan. A new array of tasks ends any draft. setTasks(saved.tasks); setLinks(saved.links); setStatus('The server refused the plan: the last saved one is back.'); } finally { setSaving(false); setEditing(false); } };
return ( <> <div role="toolbar"> {editing ? ( <> <button type="button" onClick={() => void save()} disabled={!dirty || saving}> Save </button> <button type="button" disabled={saving} onClick={() => { gantt.edit.rollback(); // `onEditRollback` puts your data back setEditing(false); setStatus('Cancelled: the plan is as it was.'); }} > Cancel </button> </> ) : ( <button type="button" onClick={() => { gantt.edit.begin(); // starts a session: the chart remembers the data it has now setEditing(true); setStatus('Drag the bars, then Save or Cancel.'); }} > Edit </button> )} <label> <input type="checkbox" checked={refuse} onChange={(e) => setRefuse(e.target.checked)} />{' '} The server refuses the save </label> </div> {/* `useGantt` is <Gantt> without the `div`: you render the container, and the slots next to it. */} <div ref={setContainer} style={{ height: 360 }} /> {slots} <p>{status}</p> </> );}import type { Link, SessionChanges } from '@ganttlane/dom';
/** * The id a link has when it has none of its own: the chart's rule, which `onLinkDelete` reports * the link by. * * @param link - A link of your list. * @returns Its id. */export function linkId(link: Link): string { return link.id ?? `${String(link.source)}:${link.type ?? 'FS'}:${String(link.target)}`;}
/** * The links after a cancelled session: drop what the session added, bring back what it removed. * `added` and `removed` are the chart's link models; `model.link` is your own `Link`. * * @param links - Your links now. * @param changes - The `links` of what the chart reports in `onEditRollback`. * @returns The links as they were when the session began. */export function restoreLinks( links: readonly Link[], { added, removed }: SessionChanges['links'],): Link[] { const dropped = new Set(removed.map((model) => model.id)); return [ ...links.filter((link) => !dropped.has(linkId(link))), ...added.flatMap((model) => model.link ?? []), ];}
/** * Stands for your API: resolves when the server keeps the plan, rejects when it does not. * * @param refuse - Whether the server says no, to see the last saved plan come back. * @returns A promise of the answer, after a short wait. */export function savePlan(refuse: boolean): Promise<void> { return new Promise((resolve, reject) => { setTimeout(() => { if (refuse) reject(new Error('The server refused the plan')); else resolve(); }, 400); });}Files
data.ts: the plan as the server last saved it.options.ts: controlled data, view mode (editing: false), the first scroll.helpers.ts:savePlan, which stands for your API,linkId, andrestoreLinks.vanilla.ts:gantt.edit,setOptions({ editing })and the dirty flag as an event.react.tsx:useGanttanduseGanttState, with the container rendered by hand.
Options
| Option | Value | What it does |
|---|---|---|
dataMode |
'controlled' |
The chart never changes your tasks (React: the tasks prop makes it controlled) |
editing |
false, then true |
Editing from the UI: view mode until Edit; your own calls still work |
onTasksChange |
a function | Every drag: write the ChangeSet into your tasks with patchTasks |
onEditRollback |
a function | A cancelled session: the inverse to write into your tasks and your links |
onDirtyChange |
a function (vanilla) | edit.isDirty flipped: Save is only useful when there are changes |
onLinkCreate/onLinkDelete |
functions (vanilla) | A link drawn or removed; onLinksChange in React gives the whole list |
viewMode |
'week' |
The scale of the timeline |
scroll.initial |
'first-task' |
Opens on the plan |
What to try
Press Edit and drag a bar: its successors follow. Save sends the plan; Cancel puts the plan back as it was when you pressed Edit, including a link you drew or removed. Tick the checkbox and Save: the last saved plan comes back. A task dragged away and back is not listed among the changes of the session.
Notes
useGantt(options)is<Gantt>without thediv: you render the container yourself (ref={gantt.setContainer}) andgantt.slotsnext to it, which is where React-rendered tooltips and labels go. Use<Gantt ref={…}>andref.current?.edit.begin()if you prefer a component.tasksgoing in as a prop makes the chart controlled from its first render. The same array again is nothing; a new array, even with equal contents, is your answer to the change and ends the draft. So derive nothing fromtasksin the render body withoutuseMemo, or any re-render (thesavingflag!) would discard the change that is being saved.patchTasks(tasks, changes, model)takes themodelof the core that computed the change set, which iscore.getSnapshot().state.derived.model.edit.commit()returns{ status: 'committed', changes }: the net difference between the start of the session and now; any other status ('no-session','busy') means nothing was committed. The data is already in your state, since every drag was applied as it happened; send the whole plan, orchangesas a diff.edit.rollback()never asksonBeforeTaskChange(a cancel is not vetoed). In controlled mode its whole effect is theonEditRollback(changes)event: applychangesto your tasks withpatchTasks, and dropchanges.links.removed/ restorechanges.links.addedin your links.- Tasks that depend on others keep following: with the default
scheduling.propagation('preserve-lag') a drag moves the successors, and they are all in the oneChangeSet. - The simpler case, where every change is saved at once, is Controlled data.