Before-hooks
Two hooks run before something is committed, and can stop it. onBeforeTaskChange sees each
change set (a drag, or a call of updateTask) and onBeforeLinkCreate each new link (a drag, or
addLink). A hook answers true to go on, false to cancel, and, for a change, a changed
ChangeSet to put in place of the one it was given. It may also answer later, with a promise:
the chart is busy until the answer comes, and refuses new gestures meanwhile.
The select picks what the task hook does, and the box whether the link hook refuses links into the frozen Release group. The line under the chart tells what the hook did and what the chart reported.
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. Design is finished (100%); Release is a frozen group: Release itself and// its two children cannot be dragged, and the link hook of the example refuses links into them.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'api', name: 'API', start: '2026-03-09', end: '2026-03-13', progress: 60 }, { id: 'ui', name: 'UI', start: '2026-03-09', end: '2026-03-17', progress: 30 }, { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-19' }, { id: 'qa', name: 'QA', start: '2026-03-18', end: '2026-03-20' }, { id: 'release', name: 'Release', start: '2026-03-23', end: '2026-03-27', locked: true }, { id: 'package', name: 'Package', parentId: 'release', start: '2026-03-23', end: '2026-03-24', locked: true, }, { id: 'publish', name: 'Publish', parentId: 'release', start: '2026-03-25', end: '2026-03-27', locked: true, },];
export const links: Link[] = [ { source: 'design', target: 'api' }, { source: 'design', target: 'ui' }, { source: 'api', target: 'qa' }, { source: 'ui', target: 'qa' },];
/** The tasks of the frozen group: Release and its children. */export const frozen: ReadonlySet<Task['id']> = new Set( tasks .filter((task) => task.id === 'release' || task.parentId === 'release') .map((task) => task.id),);import type { GanttOptions } from '@ganttlane/dom';
// The hooks themselves (`onBeforeTaskChange` and `onBeforeLinkCreate`) are in `hooks.ts`: they// need the example's state, which the select and the checkbox change.export const options = { viewMode: 'week', scroll: { initial: 'first-task' }, // Smaller rows, so that all eight tasks are in view at once. layout: { rowHeight: 36, barHeight: 24 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type Gantt, type TaskId } from '@ganttlane/dom';
import { links, tasks } from './data';import { createHooks, type HookEnv, refusalText, type Rule, RULES } from './hooks';import { options } from './options';
const NOTHING = 'Nothing is waiting for an answer.';
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const { signal } = controller; const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '370px'; const status = document.createElement('p'); status.textContent = 'Drag a bar, or its progress handle, and watch what the hook does. Link Docs or QA to Release.';
// The rule of the task hook, and whether the link hook refuses links into Release. const rule = document.createElement('select'); rule.setAttribute('aria-label', 'What onBeforeTaskChange does'); for (const { id, label } of RULES) { const option = document.createElement('option'); option.value = id; option.textContent = label; rule.append(option); } const ruleLabel = document.createElement('label'); ruleLabel.append('onBeforeTaskChange: ', rule); const refuse = document.createElement('input'); refuse.type = 'checkbox'; refuse.checked = true; const refuseLabel = document.createElement('label'); refuseLabel.append(refuse, ' onBeforeLinkCreate refuses links into Release'); // Whether the chart is busy: a hook has not answered yet. const busy = document.createElement('span'); busy.className = 'hooks-busy'; toolbar.append(ruleLabel, refuseLabel, busy);
// Where an answer that comes later is asked for: the hook returns a promise, and these buttons // settle it. const ask = document.createElement('div'); ask.setAttribute('role', 'group'); ask.setAttribute('aria-label', 'Answer to the hook'); ask.className = 'hooks-ask'; const question = document.createElement('span'); question.textContent = NOTHING; const approve = document.createElement('button'); approve.type = 'button'; approve.textContent = 'Approve'; const reject = document.createElement('button'); reject.type = 'button'; reject.textContent = 'Reject'; ask.append(question, approve, reject); root.append(toolbar, ask, chart, status); container.append(root);
// The lines of the status: what happened to the last action, in the order it happened. let lines: string[] = []; let answer: ((approved: boolean) => void) | undefined; const settle = (approved: boolean): void => { answer?.(approved); answer = undefined; question.textContent = NOTHING; approve.disabled = reject.disabled = true; }; settle(false); approve.addEventListener( 'click', () => { settle(true); }, { signal }, ); reject.addEventListener( 'click', () => { settle(false); }, { signal }, );
// The handlers read `gantt`, which they are passed to: give it a type. const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id); const env: HookEnv = { get rule() { return rule.value as Rule; }, get refuseLinks() { return refuse.checked; }, nameOf, begin: () => { lines = []; }, say: (text) => { lines.push(text); status.textContent = lines.join(' '); }, ask: (text) => new Promise((resolve) => { answer = resolve; question.textContent = text; approve.disabled = reject.disabled = false; }), };
const gantt: Gantt = createGantt(chart, { tasks, links, ...options, ...createHooks(env), // onBeforeTaskChange and onBeforeLinkCreate: see hooks.ts // What the chart reports after a change that went through. onTasksChange: (changeSet) => { const names = changeSet.changes.map((change) => nameOf(change.id)).join(', '); env.say(`Committed (${changeSet.reason}): ${names}.`); }, onLinkCreate: (link) => { env.say(`Created ${nameOf(link.source)} → ${nameOf(link.target)}.`); }, // A gesture the chart would not start: here, because a hook has not answered (`busy`). onEditRefused: (refusal) => { env.say(refusalText(refusal, nameOf)); }, });
// `busy` is in the snapshot of the core, which tells its subscribers whenever it changes. const showBusy = (): void => { const isBusy = gantt.core.getSnapshot().busy; busy.textContent = `busy: ${isBusy ? 'yes' : 'no'}`; busy.toggleAttribute('data-busy', isBusy); }; const unsubscribe = gantt.core.subscribe(showBusy); showBusy();
return () => { controller.abort(); unsubscribe(); // A hook that is still waiting must not be left hanging. settle(false); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { type GanttInstance, type TaskId, useGantt, useGanttState } from '@ganttlane/react';import { useState } from 'react';
import { links, tasks } from './data';import { createHooks, type HookEnv, refusalText, type Rule, RULES } from './hooks';import { options } from './options';
const NOTHING = 'Nothing is waiting for an answer.';
export default function Example() { // The rule of the task hook, and whether the link hook refuses links into Release. const [rule, setRule] = useState<Rule>('allow'); const [refuse, setRefuse] = useState(true); // The lines of the status: what happened to the last action, in the order it happened. const [lines, setLines] = useState<readonly string[]>([ 'Drag a bar, or its progress handle, and watch what the hook does. Link Docs or QA to Release.', ]); // The question of a hook that answers later, and the function that settles its promise. const [waiting, setWaiting] = useState<{ question: string; answer: (approved: boolean) => void; }>(); const settle = (approved: boolean): void => { waiting?.answer(approved); setWaiting(undefined); }; // `viewMode` is a controlled prop in React: `defaultViewMode` is where the chart starts, and // then the chart owns it. const { viewMode, ...rest } = options;
// The handlers read `gantt`, which they are passed to: give it a type. const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id); const say = (text: string): void => { setLines((current) => [...current, text]); }; const env: HookEnv = { rule, refuseLinks: refuse, nameOf, begin: () => { setLines([]); }, say, ask: (question) => new Promise((answer) => { setWaiting({ question, answer }); }), };
const gantt: GanttInstance = useGantt({ ...rest, defaultViewMode: viewMode, // `tasks` and `links` are controlled props in React: these are where the chart starts. defaultTasks: tasks, defaultLinks: links, ...createHooks(env), // onBeforeTaskChange and onBeforeLinkCreate: see hooks.ts // What the chart reports after a change that went through. onTasksChange: (changeSet) => { const names = changeSet.changes.map((change) => nameOf(change.id)).join(', '); say(`Committed (${changeSet.reason}): ${names}.`); }, onLinkCreate: (link) => { say(`Created ${nameOf(link.source)} → ${nameOf(link.target)}.`); }, // A gesture the chart would not start: here, because a hook has not answered (`busy`). onEditRefused: (refusal) => { say(refusalText(refusal, nameOf)); }, }); // Destructured, which keeps `react-hooks/refs` quiet about the ref callback. const { setContainer, slots } = gantt;
// `busy` is in the snapshot of the chart: true while a hook has not answered. const busy = useGanttState(gantt, (snapshot) => snapshot.busy);
return ( <> <div role="toolbar"> <label> onBeforeTaskChange:{' '} <select aria-label="What onBeforeTaskChange does" value={rule} onChange={(event) => setRule(event.target.value as Rule)} > {RULES.map(({ id, label }) => ( <option key={id} value={id}> {label} </option> ))} </select> </label> <label> <input type="checkbox" checked={refuse} onChange={() => setRefuse(!refuse)} />{' '} onBeforeLinkCreate refuses links into Release </label> <span className="hooks-busy" data-busy={busy ? '' : undefined}> busy: {busy ? 'yes' : 'no'} </span> </div> {/* Where an answer that comes later is asked for: the hook returns a promise, and these buttons settle it. */} <div role="group" aria-label="Answer to the hook" className="hooks-ask"> <span>{waiting?.question ?? NOTHING}</span> <button type="button" disabled={waiting === undefined} onClick={() => settle(true)}> Approve </button> <button type="button" disabled={waiting === undefined} onClick={() => settle(false)}> Reject </button> </div> {/* `useGantt` is <Gantt> without the `div`: you render the container, and the slots next to it. */} <div ref={setContainer} style={{ height: 370 }} /> {slots} <p>{lines.join(' ')}</p> </> );}import type { ChangeSet, EditRefusedPayload, GanttOptions, TaskId } from '@ganttlane/dom';
import { frozen } from './data';
/** What `onBeforeTaskChange` does, by the rule the select picks. */export type Rule = 'allow' | 'veto' | 'modify' | 'ask';
/** The rules, for the select. */export const RULES: readonly { readonly id: Rule; readonly label: string }[] = [ { id: 'allow', label: 'Let everything through' }, { id: 'veto', label: 'Veto changes to finished work' }, { id: 'modify', label: 'Rewrite: progress at most 80%' }, { id: 'ask', label: 'Ask first (answers later)' },];
/** The most progress the `'modify'` rule lets a drag give a task. */const CAP = 80;
/** What the hooks need from the example around them. Reads are current: they are made when asked. */export interface HookEnv { /** The rule of the select. */ readonly rule: Rule; /** Whether the link hook refuses links into the frozen Release group (the checkbox). */ readonly refuseLinks: boolean; /** The name of a task from its id. */ nameOf(id: TaskId): string; /** A new action starts: the lines of the status begin again. */ begin(): void; /** One more line of the status. */ say(text: string): void; /** Shows a question with Approve and Reject, and resolves with the answer (later). */ ask(question: string): Promise<boolean>;}
/** * The two before-hooks. A hook may answer at once or later (a promise), and the answer is * `true` (go on), `false` (cancel) or, for a change, a `ChangeSet` that replaces the one being * committed. While a promise is pending the chart is busy: the user's gestures and the * programmatic calls are refused until the answer comes. * * @param env - The example's state and its way of talking to the page. * @returns The options to give to the chart. */export function createHooks( env: HookEnv,): Pick<GanttOptions, 'onBeforeTaskChange' | 'onBeforeLinkCreate'> { /** Asks the question and says so when the answer is no; resolves with the answer. */ const confirm = async (question: string, onNo: string): Promise<boolean> => { const approved = await env.ask(question); if (!approved) env.say(onNo); return approved; };
return { // Before a drag or `updateTask` is committed: `changeSet` has every task it moves, with // `before` and `after` values; `reason` says what made it (`'move'`, `'progress'`, `'api'`, …). onBeforeTaskChange: ({ changeSet, reason }) => { env.begin(); switch (env.rule) { case 'veto': // A change set that touches finished work, directly or through a link, is cancelled. if (changeSet.changes.some((change) => change.before.progress === 100)) { env.say('The hook said no: finished work does not change.'); return false; } return true; case 'modify': { // Answering with a change set replaces the one being committed (it is checked again). const capped = capProgress(changeSet); if (capped === undefined) return true; env.say(`The hook rewrote the change: progress stops at ${CAP}%.`); return capped; } case 'ask': { // A promise: the commit waits for it, and the chart is busy meanwhile. const names = changeSet.changes.map((change) => env.nameOf(change.id)).join(', '); return confirm( `Apply this ${reason} of ${names}?`, 'You said no: the change is cancelled.', ); } case 'allow': return true; } },
// Before a link is created, by a drag or by `addLink`: `link`, its `id`, and // `wouldCreateCycle`, which only explains: a cycle is refused whatever the hook says. onBeforeLinkCreate: ({ link }) => { env.begin(); if (env.refuseLinks && frozen.has(link.target)) { env.say( `The hook refused the link: ${env.nameOf(link.target)} is frozen and takes no new dependency.`, ); return false; } if (env.rule === 'ask') { const question = `Create the link ${env.nameOf(link.source)} → ${env.nameOf(link.target)}?`; return confirm(question, 'You said no: the link is not created.'); } return true; }, };}
/** * The change set with the progress of every task that a drag raised past the cap lowered to it. * * @param changeSet - The change set being committed. * @returns A new change set, or `undefined` when nothing needs lowering. */function capProgress(changeSet: ChangeSet): ChangeSet | undefined { const raised = ({ before, after }: ChangeSet['changes'][number]): boolean => after.progress > CAP && after.progress > before.progress; if (!changeSet.changes.some(raised)) return undefined; return { ...changeSet, changes: changeSet.changes.map((change) => raised(change) ? { ...change, after: { ...change.after, progress: CAP } } : change, ), };}
/** A line for the status: what `onEditRefused` reported. */export function refusalText(refusal: EditRefusedPayload, nameOf: (id: TaskId) => string): string { const { gesture, id, reason } = refusal; return reason === 'busy' ? `Refused (busy): ${gesture} of ${nameOf(id)}. The chart waits for the answer to a hook.` : `Refused (${reason}): ${gesture} of ${nameOf(id)}.`;}/* * Your own styles for what the example builds around the chart: the busy flag, and the strip where * the question of a hook that answers later is put. Colours come from the text colour of the page, * so that they fit a light and a dark page alike. */.hooks-busy { padding: 0.125rem 0.5rem; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 999px; opacity: 0.7;}
.hooks-busy[data-busy] { border-color: currentColor; opacity: 1; font-weight: 600;}
.hooks-ask { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-block-end: 0.5rem; padding: 0.25rem 0.5rem; border: 1px dashed color-mix(in srgb, currentColor 35%, transparent); border-radius: 0.375rem; font-size: 0.875rem;}
.hooks-ask span { flex: 1 1 12rem;}Files
data.ts: eight tasks (Design is finished, Release and its children are frozen) and four links.options.ts: the view mode, the first scroll and the layout.hooks.ts: the hooks: veto, rewrite, ask first, and the link rule.styles.css: your own styles for the busy flag and the strip with the question.vanilla.tsandreact.tsx: the controls, the status and the busy flag (core.subscribe,useGanttState).
Options
| Option | Value | What it does |
|---|---|---|
onBeforeTaskChange |
a function (hooks.ts) |
({ changeSet, reason, conflicts }): answers true, false, a ChangeSet or a promise of one |
onBeforeLinkCreate |
a function (hooks.ts) |
({ link, id, wouldCreateCycle }): answers true, false or a promise of one |
onTasksChange |
a function (in the example) | The change that went through |
onLinkCreate |
a function (in the example) | The link that was created |
onEditRefused |
a function (in the example) | A gesture that the chart would not start: busy while a hook waits |
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 |
What to try
- Veto. Pick Veto changes to finished work and drag Design (100%): the bar snaps back and nothing is reported but the hook’s own line. Drag API, which is not finished: it goes through, and QA follows.
- Rewrite. Pick Rewrite and drag the progress handle of API (the little square at the foot of its bar) to the right end: the chart commits 80%, the hook’s value, not yours.
- Ask first. Pick it and drag any bar. On release the bar waits where you dropped it, the flag
says
busy: yes, and the question has its two buttons. Try to drag another bar:Refused (busy). Approve and the change is committed, Reject and it is cancelled. - Links. With the box ticked, draw a link from QA onto Release (or Package, or Publish): the hook cancels it, and the chart reports no event, since nothing was refused by the chart itself. A link from Docs to QA is created. Untick the box, and Release can be linked. In Ask first both hooks ask.
Notes
- What a hook may answer.
trueor nothing goes on;falsecancels (a drag snaps back, andupdateTaskandaddLinkresolvecancelledwith the reason'hook'); aChangeSetreplaces the one being committed, and is checked again, so a value is aligned to the granularity and a task that no longer changes is dropped. A hook that throws or rejects cancels and reports the error toonError. busywhile a promise is pending.gantt.core.getSnapshot().busy(React:useGanttState(gantt, (snapshot) => snapshot.busy)). No gesture starts andupdateTaskresolvesbusyuntil the answer comes; the bar that was dropped is shown where it was dropped. The same flag is set while controlled data holds a change that its owner has not applied yet.- Where hooks run, and where they do not.
onBeforeTaskChangeruns for a drag and forupdateTask,onBeforeLinkCreatefor a link that is drawn and foraddLink. Removing a link is not hooked, and neither are undo, redo andedit.rollback(): a hook cannot stand in the way of putting things back. - The context.
reasonis'move','resize-start','resize-end','progress'or'api';conflictsare the warnings the change would cause (a day off, a child outside its parent): the working calendar example asks about them.wouldCreateCycleonly explains: a link that closes a cycle is rejected whatever the hook says. - Hooks are not permissions.
editing,lockedandlinkablestop a gesture from beginning (Read-only); a hook is asked at the end, with the whole change in hand. - React. The hooks are props like any handler, and always current:
createHooks(env)runs on every render, so a hook reads the state of the latest one.viewModeis a controlled prop, sodefaultViewModeis where the chart starts.