Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gantt-warn-unresolved-task-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: warn when a gantt task references an unknown `after`/`until` task id, or when its end value is neither a valid date nor a valid duration
53 changes: 46 additions & 7 deletions packages/mermaid/src/diagrams/gantt/ganttDb.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,21 @@ const fixTaskDates = function (startTime, endTime, dateFormat, excludes, include
return [endTime, renderEndTime];
};

/**
* Logs a warning about task ids that an `after` or `until` statement could not resolve.
*
* Unknown references fail silently and fall back to today's date, which makes typos and
* missing ids hard to spot. See https://github.com/mermaid-js/mermaid/issues/4121.
*
* @param {'after' | 'until'} keyword - The keyword holding the references.
* @param {string[]} ids - The ids that could not be found.
*/
const warnAboutUnknownTaskIds = function (keyword, ids) {
log.warn(
`Gantt: the "${keyword}" statement references unknown task id(s): ${ids.join(', ')}. Make sure the referenced tasks exist and declare an id. Milestones need both an id and a duration, e.g. "Milestone :milestone, m1, 2023-01-01, 0d".`
);
};

const getStartDate = function (prevTime, dateFormat, str) {
str = str.trim();

Expand All @@ -307,13 +322,23 @@ const getStartDate = function (prevTime, dateFormat, str) {
if (afterStatement !== null) {
// check all after ids and take the latest
let latestTask = null;
for (const id of afterStatement.groups.ids.split(' ')) {
let task = findTaskById(id);
if (task !== undefined && (!latestTask || task.endTime > latestTask.endTime)) {
const unknownIds = [];
const referencedIds = afterStatement.groups.ids.split(' ').filter((id) => id !== '');
for (const id of referencedIds) {
const task = findTaskById(id);
if (task === undefined) {
unknownIds.push(id);
continue;
}
if (!latestTask || task.endTime > latestTask.endTime) {
latestTask = task;
}
}

if (unknownIds.length > 0) {
warnAboutUnknownTaskIds('after', unknownIds);
}

if (latestTask) {
return latestTask.endTime;
}
Expand Down Expand Up @@ -391,13 +416,23 @@ const getEndDate = function (prevTime, dateFormat, str, inclusive = false) {
if (untilStatement !== null) {
// check all until ids and take the earliest
let earliestTask = null;
for (const id of untilStatement.groups.ids.split(' ')) {
let task = findTaskById(id);
if (task !== undefined && (!earliestTask || task.startTime < earliestTask.startTime)) {
const unknownIds = [];
const referencedIds = untilStatement.groups.ids.split(' ').filter((id) => id !== '');
for (const id of referencedIds) {
const task = findTaskById(id);
if (task === undefined) {
unknownIds.push(id);
continue;
}
if (!earliestTask || task.startTime < earliestTask.startTime) {
earliestTask = task;
}
}

if (unknownIds.length > 0) {
warnAboutUnknownTaskIds('until', unknownIds);
}

if (earliestTask) {
return earliestTask.startTime;
}
Expand All @@ -417,7 +452,11 @@ const getEndDate = function (prevTime, dateFormat, str, inclusive = false) {

let endTime = dayjs(prevTime);
const [durationValue, durationUnit] = parseDuration(str);
if (!Number.isNaN(durationValue)) {
if (Number.isNaN(durationValue)) {
log.warn(
`Gantt: "${str}" is neither a valid date for the "${dateFormat.trim()}" date format nor a valid duration (e.g. "3d"), so it is ignored and the task gets a zero duration. Milestones need a duration too, e.g. "Milestone :milestone, m1, 2023-01-01, 0d".`
);
} else {
const newEndTime = endTime.add(durationValue, durationUnit);
if (newEndTime.isValid()) {
endTime = newEndTime;
Expand Down
51 changes: 51 additions & 0 deletions packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import dayjs from 'dayjs';
import ganttDb from './ganttDb.js';
import { convert } from '../../tests/util.js';
import { log } from '../../logger.js';

describe('when using the ganttDb', function () {
beforeEach(function () {
Expand Down Expand Up @@ -566,4 +567,54 @@ describe('when using the ganttDb', function () {
// Second task will be parsed as year 202 (fallback to new Date())
expect(tasks[1].startTime.getFullYear()).toBe(202);
});

describe('when a task references a task id that does not exist', function () {
it('should warn about unknown ids in an "after" statement (issue #4121)', function () {
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
ganttDb.setDateFormat('YYYY-MM-DD');
ganttDb.addTask('task1', 'id1,2013-01-01,2d');
ganttDb.addTask('task2', 'id2,after m1,1d');

ganttDb.getTasks();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('m1'));
warnSpy.mockRestore();
});

it('should warn about unknown ids in an "until" statement', function () {
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
ganttDb.setDateFormat('YYYY-MM-DD');
ganttDb.addTask('task1', 'id1,2013-01-01,until m1');

ganttDb.getTasks();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('m1'));
warnSpy.mockRestore();
});

it('should not warn when every referenced id exists', function () {
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
ganttDb.setDateFormat('YYYY-MM-DD');
ganttDb.addTask('task1', 'id1,2013-01-01,2d');
ganttDb.addTask('task2', 'id2,after id1,1d');

ganttDb.getTasks();

expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});

it('should warn when a milestone is declared without a duration (issue #4121)', function () {
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
ganttDb.setDateFormat('YYYY-MM-DD');
// Without a duration, `m1` is parsed as the duration instead of as the id
ganttDb.addTask('M1', 'milestone, 2023-01-01, m1');

const tasks = ganttDb.getTasks();

expect(tasks[0].endTime).toEqual(tasks[0].startTime);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('m1'));
warnSpy.mockRestore();
});
});
});
Loading