diff --git a/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json b/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json new file mode 100644 index 000000000..7f44ce2b8 --- /dev/null +++ b/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-gantt", + "comment": "Fix a crash in the Gantt component when `zoomScale` is configured without `timelineHeader.scales`, and initialize timeline scales from the active zoom level or a safe default (GitHub #5159)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-gantt", + "email": "53095992+Jian-Zhang08@users.noreply.github.com" +} diff --git a/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts new file mode 100644 index 000000000..ee14e82c5 --- /dev/null +++ b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts @@ -0,0 +1,86 @@ +// @ts-nocheck + +global.__VERSION__ = 'none'; + +import { Gantt } from '../src/index'; + +describe('Gantt._sortScales', () => { + // Regression test for https://github.com/VisActor/VTable/issues/5159 + // When only `zoomScale` is configured (so `timelineHeader.scales` is still + // undefined), `_sortScales` used to crash on `timelineScales.length`. + test('does not throw when timelineHeader.scales is undefined', () => { + const context = { + options: { timelineHeader: {} }, + parsedOptions: {} + }; + + expect(() => Gantt.prototype._sortScales.call(context)).not.toThrow(); + expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['day']); + expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day']); + }); + + test('does not share mutable default scale objects across calls', () => { + const firstContext = { + options: {}, + parsedOptions: {} + }; + const secondContext = { + options: {}, + parsedOptions: {} + }; + + Gantt.prototype._sortScales.call(firstContext); + firstContext.parsedOptions.sortedTimelineScales[0].timelineDates = ['stale']; + Gantt.prototype._sortScales.call(secondContext); + + expect(secondContext.parsedOptions.sortedTimelineScales[0].timelineDates).toBeUndefined(); + }); + + test('still sorts the configured scales', () => { + const context = { + options: { timelineHeader: { scales: [{ unit: 'day' }, { unit: 'month' }] } }, + parsedOptions: {} + }; + + Gantt.prototype._sortScales.call(context); + + expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['month', 'day']); + expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day', 'month']); + }); + + test('uses zoomScale current level when timelineHeader.scales is undefined', () => { + const context = { + options: { + timelineHeader: { + zoomScale: { + enabled: true, + levels: [ + [ + { unit: 'month', step: 1 }, + { unit: 'day', step: 1 } + ] + ] + } + } + }, + zoomScaleManager: { + config: { + levels: [ + [ + { unit: 'month', step: 1 }, + { unit: 'day', step: 1 } + ] + ] + }, + getCurrentLevel: () => 0 + }, + parsedOptions: {} + }; + + Gantt.prototype._sortScales.call(context); + + expect(context.options.timelineHeader.scales.map(scale => scale.unit)).toEqual(['month', 'day']); + expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['month', 'day']); + expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day', 'month']); + }); +}); diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts index 4084a9713..bf77edc65 100644 --- a/packages/vtable-gantt/src/Gantt.ts +++ b/packages/vtable-gantt/src/Gantt.ts @@ -84,6 +84,9 @@ export function createRootElement(padding: any, className: string = 'vtable-gant return element; } + +const DEFAULT_TIMELINE_SCALE: ITimelineScale = { unit: 'day', step: 1 }; + export class Gantt extends EventTarget { options: GanttConstructorOptions; container: HTMLElement; @@ -689,45 +692,58 @@ export class Gantt extends EventTarget { _sortScales() { const { timelineHeader } = this.options; - if (timelineHeader) { - const timelineScales = timelineHeader.scales; - const sortOrder = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second']; - if (timelineScales.length === 1) { - if ( - timelineScales[0].unit === 'hour' || - timelineScales[0].unit === 'minute' || - timelineScales[0].unit === 'second' - ) { - this.parsedOptions.timeScaleIncludeHour = true; - } - } - const orderedScales = timelineScales.slice().sort((a, b) => { - if (a.unit === 'hour' || a.unit === 'minute' || a.unit === 'second') { - this.parsedOptions.timeScaleIncludeHour = true; - } - const indexA = sortOrder.indexOf(a.unit); - const indexB = sortOrder.indexOf(b.unit); - if (indexA === -1) { - return 1; - } else if (indexB === -1) { - return -1; - } - return indexA - indexB; - }); - const reverseOrderedScales = timelineScales.slice().sort((a, b) => { - const indexA = sortOrder.indexOf(a.unit); - const indexB = sortOrder.indexOf(b.unit); - if (indexA === -1) { - return 1; - } else if (indexB === -1) { - return -1; - } - return indexB - indexA; - }); + const zoomLevelScales = this.zoomScaleManager?.config.levels[this.zoomScaleManager.getCurrentLevel()]; + const defaultTimelineScale: ITimelineScale = { ...DEFAULT_TIMELINE_SCALE }; + let timelineScales: ITimelineScale[]; + if (timelineHeader?.scales?.length > 0) { + timelineScales = timelineHeader.scales; + } else if (zoomLevelScales?.length > 0) { + timelineScales = zoomLevelScales; + } else { + timelineScales = [defaultTimelineScale]; + } + + if (timelineHeader && (!timelineHeader.scales || timelineHeader.scales.length === 0)) { + timelineHeader.scales = timelineScales.map(scale => ({ ...scale })); + } - this.parsedOptions.sortedTimelineScales = orderedScales; - this.parsedOptions.reverseSortedTimelineScales = reverseOrderedScales; + const sortOrder = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second']; + this.parsedOptions.timeScaleIncludeHour = false; + if (timelineScales.length === 1) { + if ( + timelineScales[0].unit === 'hour' || + timelineScales[0].unit === 'minute' || + timelineScales[0].unit === 'second' + ) { + this.parsedOptions.timeScaleIncludeHour = true; + } } + const orderedScales = timelineScales.slice().sort((a, b) => { + if (a.unit === 'hour' || a.unit === 'minute' || a.unit === 'second') { + this.parsedOptions.timeScaleIncludeHour = true; + } + const indexA = sortOrder.indexOf(a.unit); + const indexB = sortOrder.indexOf(b.unit); + if (indexA === -1) { + return 1; + } else if (indexB === -1) { + return -1; + } + return indexA - indexB; + }); + const reverseOrderedScales = timelineScales.slice().sort((a, b) => { + const indexA = sortOrder.indexOf(a.unit); + const indexB = sortOrder.indexOf(b.unit); + if (indexA === -1) { + return 1; + } else if (indexB === -1) { + return -1; + } + return indexB - indexA; + }); + + this.parsedOptions.sortedTimelineScales = orderedScales; + this.parsedOptions.reverseSortedTimelineScales = reverseOrderedScales; } _generateTimeLineDateMap() {