Issues
A chart does not throw on bad input. A link to a task that is not there, a cycle, a date that is not
a date, an end before its start, a value that is not a multiple of the data step, an option that
is not one of its values: each is reported as an Issue, the rest of the data is kept, and what
can be drawn is drawn. This example feeds the chart one of each on purpose and lists what comes back,
from getIssues() and from the onIssues handler. Fix the input puts a clean plan in, and the
list empties by itself.
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';
// Bad input on purpose. The chart never throws on it: it reports each problem as an `Issue`// (`gantt.getIssues()`, `onIssues`), keeps the rest of the data, and draws what it can.// 2026-03-02 is a Monday; a task with an `end` ends on that day (inclusive).export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06' }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-13' },
// `end-before-start` (error): the end is a day before the start. The task has no bar. { id: 'review', name: 'Review', start: '2026-03-16', end: '2026-03-12' },
// `invalid-date` (error): the start is no date. The task has no bar. { id: 'qa', name: 'QA', start: 'next tuesday', duration: 3 },
// `misaligned` (warning): the data step is a day, and 10:30 is not a multiple of it. The task // is kept, and its start is aligned (`scheduling.alignInput`); `suggested` says to what. { id: 'release', name: 'Release', start: '2026-03-16T10:30', duration: 2 },
// Two tasks that wait for each other, see the links. { id: 'docs', name: 'Docs', start: '2026-03-09', end: '2026-03-11' }, { id: 'marketing', name: 'Marketing', start: '2026-03-11', end: '2026-03-13' },];
export const links: Link[] = [ { source: 'design', target: 'build' }, // `unknown-link-task` (error): there is no task `ghost`. The link is dropped. { source: 'build', target: 'ghost' }, // `cycle` (error): Docs waits for Marketing, which waits for Docs. The links are kept and // drawn, dashed and red, and scheduling ignores them. { source: 'docs', target: 'marketing' }, { source: 'marketing', target: 'docs' },];
// The same plan without the mistakes: what the button puts in.export const fixedTasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06' }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-13' }, { id: 'review', name: 'Review', start: '2026-03-16', end: '2026-03-18' }, { id: 'qa', name: 'QA', start: '2026-03-17', duration: 3 }, { id: 'release', name: 'Release', start: '2026-03-16', duration: 2 }, { id: 'docs', name: 'Docs', start: '2026-03-09', end: '2026-03-11' }, { id: 'marketing', name: 'Marketing', start: '2026-03-12', end: '2026-03-14' },];
export const fixedLinks: Link[] = [ { source: 'design', target: 'build' }, { source: 'build', target: 'review' }, { source: 'docs', target: 'marketing' },];import type { GanttOptions } from '@ganttlane/dom';
// Values that come from outside the reach of TypeScript (a config file, a form, a query string):// plain strings, which nothing in their type ties to an option. The casts below are only here so// that this file compiles; without them the compiler would refuse the values, as it should, and// the chart has to cope with them all the same.const propagation: string = 'sideways';const gridLines: string = 'diagonal';
export const options = { // Not a propagation policy: an `invalid-option` issue, whose `suggested` is the default that is // used instead ('preserve-lag'). A group of options is checked field by field. scheduling: { propagation: propagation as 'none' }, // The same for the options the renderer owns: the path says where, `display.gridLines`. display: { gridLines: gridLines as 'both' }, // One column a week: the whole plan fits. viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type Issue } from '@ganttlane/dom';
import { fixedLinks, fixedTasks, links, tasks } from './data';import { detailOf, type Found, locationOf } from './helpers';import { options } from './options';
/** One issue as a list item: its code, its place, its text, and what was given and used. */function issueItem(issue: Issue): HTMLLIElement { const item = document.createElement('li'); item.dataset.severity = issue.severity; const code = document.createElement('strong'); code.textContent = issue.code; const where = document.createElement('code'); where.textContent = ` ${locationOf(issue)}`; // `message` is for people: the development build spells it out, the production build keeps the // short form. Never parse it: `code`, `path`, `value` and `suggested` say the same. const message = document.createElement('small'); message.textContent = issue.message; const detail = document.createElement('small'); detail.textContent = detailOf(issue); item.append(code, where, message, detail); return item;}
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); root.className = 'issues-demo'; const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const body = document.createElement('div'); body.className = 'issues-demo__body'; // The chart fills its container and scrolls inside it, so the container needs a height. const chart = document.createElement('div'); chart.style.height = '420px'; const panel = document.createElement('aside'); panel.className = 'issues-demo__panel'; panel.setAttribute('aria-label', 'Issues'); body.append(chart, panel); const status = document.createElement('p'); root.append(toolbar, body, status); container.append(root);
// The list is drawn from the two lists of `getIssues()`, and again each time `onIssues` says one // of them changed. Nodes are built with the DOM API: issue texts are data, never markup. const found: Found = { data: [], options: [] }; const render = (): void => { const sections = (['data', 'options'] as const).map((source) => { const section = document.createElement('section'); const title = document.createElement('h3'); title.textContent = `${source === 'data' ? 'Data' : 'Options'} (${String(found[source].length)})`; section.append(title); if (found[source].length === 0) { const none = document.createElement('p'); none.className = 'issues-demo__empty'; none.textContent = 'No issues.'; section.append(none); } else { const list = document.createElement('ul'); list.append(...found[source].map(issueItem)); section.append(list); } return section; }); panel.replaceChildren(...sections); };
const gantt = createGantt(chart, { tasks, links, ...options, // Called when the list of issues of the data (`'data'`) or of the options (`'options'`) // changes, also while the chart is created. It carries the whole list, not a difference. onIssues: ({ source, issues }): void => { if (source === 'change-set') return; // a change set that was committed with issues found[source] = issues; status.textContent = `onIssues: ${source}, ${String(issues.length)} issues.`; render(); }, }); // The same lists, asked for at any time. Object.assign(found, gantt.getIssues()); render();
const controller = new AbortController(); const { signal } = controller;
const addButton = (label: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal }); toolbar.append(button); };
addButton('Fix the input', () => { gantt.setTasks(fixedTasks); gantt.setLinks(fixedLinks); // `undefined` puts an option back to its default. gantt.setOptions({ scheduling: { propagation: undefined }, display: { gridLines: undefined } }); }); addButton('Break it again', () => { gantt.setTasks(tasks); gantt.setLinks(links); gantt.setOptions({ scheduling: options.scheduling, display: options.display }); });
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 { fixedLinks, fixedTasks, links, tasks } from './data';import { detailOf, type Found, locationOf } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [fixed, setFixed] = useState(false); // The two lists, kept as `onIssues` reports them. const [found, setFound] = useState<Found>({ data: [], options: [] }); const [status, setStatus] = useState('');
return ( <div className="issues-demo"> <div role="toolbar"> {/* The ref is the chart's methods. */} <button type="button" onClick={() => { chart.current?.setTasks(fixedTasks); chart.current?.setLinks(fixedLinks); setFixed(true); }} > Fix the input </button> <button type="button" onClick={() => { chart.current?.setTasks(tasks); chart.current?.setLinks(links); setFixed(false); }} > Break it again </button> </div> <div className="issues-demo__body"> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 420 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...options} // In React the props are the truth: the options are passed from state after the spread, // and left out (`undefined`) once fixed, which puts them back to their defaults. scheduling={fixed ? undefined : options.scheduling} display={fixed ? undefined : options.display} // Called when the list of issues of the data (`'data'`) or of the options (`'options'`) // changes, also while the chart is created. It carries the whole list. onIssues={({ source, issues }) => { if (source === 'change-set') return; // a change set that was committed with issues setFound((previous) => ({ ...previous, [source]: issues })); setStatus(`onIssues: ${source}, ${String(issues.length)} issues.`); }} /> <aside className="issues-demo__panel" aria-label="Issues"> {(['data', 'options'] as const).map((source) => ( <section key={source}> <h3> {source === 'data' ? 'Data' : 'Options'} ({found[source].length}) </h3> {found[source].length === 0 ? ( <p className="issues-demo__empty">No issues.</p> ) : ( <ul> {found[source].map((issue, index) => ( <li key={index} data-severity={issue.severity}> <strong>{issue.code}</strong> <code>{locationOf(issue)}</code> {/* `message` is for people: never parse it. `code`, `path`, `value` and `suggested` say the same. */} <small>{issue.message}</small> <small>{detailOf(issue)}</small> </li> ))} </ul> )} </section> ))} </aside> </div> <p>{status}</p> </div> );}import type { Issue } from '@ganttlane/core';
/** The two lists of `getIssues()`, and of the `issues` event that carries their name as `source`. */export type Found = Record<'data' | 'options', readonly Issue[]>;
/** The ids a cycle names: `taskIds` and `linkIds` are fields of that one kind of issue. */function idsOf(issue: Issue, key: 'taskIds' | 'linkIds'): string[] { const ids: unknown = Reflect.get(issue, key); return Array.isArray(ids) ? ids.map(String) : [];}
/** A value for a sentence: a string in quotes, anything else as it is written. */function show(value: unknown): string { return typeof value === 'string' ? JSON.stringify(value) : String(value);}
/** * Says where an issue is: its `path` (`tasks[2].end`, `links[1]`, `scheduling.propagation`) and * the task or link it belongs to. A cycle has no path but names the tasks that are in it. * * @param issue - One of `getIssues().data` or `.options`. * @returns A short text. */export function locationOf(issue: Issue): string { const where = issue.path ?? `tasks ${idsOf(issue, 'taskIds').join(', ')}`; if (issue.taskId !== undefined) return `${where} (task ${String(issue.taskId)})`; return issue.linkId === undefined ? where : `${where} (link ${issue.linkId})`;}
/** * Says what the chart found in the value, and what it did about it: `value` is what you gave, and * `suggested` what it used or would use instead. * * @param issue - One of `getIssues().data` or `.options`. * @returns A short text; empty when the issue carries no value. */export function detailOf(issue: Issue): string { if (!('value' in issue)) return ''; const given = `you gave ${show(issue.value)}`; return 'suggested' in issue ? `${given}, instead: ${show(issue.suggested)}` : given;}/* * Your own stylesheet: the layout of the chart and the list of issues beside it. Colours come from * `currentcolor`, so the list follows the colours of the page. */.issues-demo__body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.75rem; align-items: start;}
/* On a narrow page the list goes under the chart. */@media (max-width: 640px) { .issues-demo__body { grid-template-columns: minmax(0, 1fr); }}
/* A little taller than the chart, and scrolls inside it. */.issues-demo__panel { max-height: 470px; overflow: auto; font-size: 0.75rem; line-height: 1.3;}
.issues-demo__panel h3 { margin: 0 0 0.25rem; font-size: 0.75rem; font-weight: 600;}
.issues-demo__panel section + section { margin-block-start: 0.625rem;}
.issues-demo__panel ul { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none;}
.issues-demo__panel li { padding: 0.125rem 0.375rem; border: 1px solid color-mix(in srgb, currentcolor 20%, transparent); border-inline-start: 3px solid #e5484d; border-radius: 0.25rem; overflow-wrap: anywhere;}
.issues-demo__panel li[data-severity='warning'] { border-inline-start-color: #f5a524;}
.issues-demo__panel li small { display: block; font-size: 0.6875rem; opacity: 0.75;}
.issues-demo__empty { opacity: 0.75;}Files
data.ts: the plan with its mistakes, each one commented with the issue it gives, and the plan without them.options.ts: two options that are not among their values.helpers.ts:locationOfanddetailOf, which turn anIssueinto text.styles.css: the layout of the chart and the list, in your own stylesheet.vanilla.tsandreact.tsx: the chart, the list and two buttons.
Options
| Option | Value | What it does |
|---|---|---|
onIssues |
a function (in the example) | Called with { source, issues } when the list of the data or of the options changes, also at creation |
gantt.getIssues() |
{ data, options } |
The current issues, whenever you ask (a method, not an option) |
scheduling.propagation |
'sideways' (not a value) |
An invalid-option: the default, 'preserve-lag', is used |
display.gridLines |
'diagonal' (not a value) |
An invalid-option of the renderer, with the full path of the value |
viewMode |
'week' |
One column a week: the whole plan fits |
scroll.initial |
'first-task' |
Opens on the plan; the default, 'today', is far from these dates |
What to try
- Read the list. The Data section has the five problems of the plan, the Options section the
two of the options. Each item is the
code, thepath(or the task or link it belongs to), themessage, and what you gave (value) and what the chart used instead (suggested). - Look at the chart for what survived. Review and QA have no bar: an error makes a task invalid and its row empty. Release is there, aligned to the day: a warning corrects a value and keeps the task. The two links that form a cycle are drawn, dashed and red.
- Press Fix the input.
setTasks,setLinksandsetOptionsgive the chart a clean plan; the chart reports the new lists withonIssues(the line under the chart), and both sections empty. - Press Break it again and the issues return.
Notes
-
The codes in this plan.
Code Where What the chart does end-before-starttasks[2].endAn error: the task is invalid, a row without a bar invalid-datetasks[3].startAn error: the task is invalid misalignedtasks[4].startA warning: aligned as scheduling.alignInputsays;suggestedis the resultunknown-link-tasklinks[1]An error: the link is dropped cycleits taskIds,linkIdsAn error: the links are kept and drawn, and scheduling ignores them invalid-optionscheduling.propagationAn error: the default is used, and suggestedis it -
Severity.
'error'means that the item is unusable and was left out or replaced;'warning'means that it was accepted, with a correction. AChangeSetcan carry issues too (source: 'change-set'), and anupdateTaskthat is refused returns them. -
getIssues()andonIssues.getIssues()is the list now, as{ data, options }.onIssuesis called with the whole new list of one of them (source:'data','options') when it changes, and also while the chart is created, so it is the one to use to keep a list on the screen up to date. The handler is a chart-level option, and so isgantt.on('issues', …). -
Unknown keys are ignored, not reported. A misspelled option,
scheduling: { propogation: 'none' }, is not aninvalid-option: the chart has never heard of the key. A known option with a value that is not one of its values is reported. TypeScript catches the first; the second is what a config file or a form can give you, which is why this example casts its two bad values. -
Some values are reserved. A few options are planned and not available yet (the toolbar, a right-to-left chart,
scheduling.propagation: 'push-forward'): they are reported asunsupported-option, and the default is used. -
messageis for people. The development build (export conditiondevelopment, which Vite sets in development) spells it out; the production build keeps the short form'<path>: <code>'. Never parse it:code,path,valueandsuggestedare the same in both builds (the one issue that only the development build gives is theunknown-optionwarning for a key the core does not know). -
React.
onIssuesis a prop, and the list is state.schedulinganddisplayare passed to<Gantt>after the spread ofoptions, from state, and left out once the input is fixed: in React the props are the truth, so an option changed withsetOptionson the ref would be overwritten by the next render.