Skip to content
Open
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
111 changes: 111 additions & 0 deletions packages/chaire-lib-common/src/utils/objects/HistoryTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2025, Polytechnique Montreal and contributors
*
* This file is licensed under the MIT License.
* License text available at https://opensource.org/licenses/MIT
*/
import _cloneDeep from 'lodash/cloneDeep';
import _isEqual from 'lodash/isEqual';

/**
* A class that keeps track of changes on an object and allows to undo/redo them
*/
export class HistoryTracker<T extends Readonly<{ [key: string]: unknown }>> {
private initialData: T;
private history: T[] = [];
private historyIndex: number = 0;

/**
* Constructor
* @param initialData The object in its initial state
* @param maxCount The maximum number of changes to keep. Defaults to 10
*/
constructor(
initialData: T,
private maxCount: number = 10
) {
this.initialData = _cloneDeep(initialData);
this.history[0] = this.initialData;
}

/**
* Check if there are changes to undo
* @returns `true` if there are changes that can be undone
*/
canUndo = () => this.historyIndex !== 0;

/**
* Check if there are available changes to redo
* @returns `true` if there are changes that can be re-done
*/
canRedo = () => this.historyIndex !== this.history.length - 1;

/**
* Return the previous value of the object
* @returns A copy of the previous value, or `undefined` if there are no changes
*/
undo = (): T | undefined => (this.historyIndex > 0 ? _cloneDeep(this.history[--this.historyIndex]) : undefined);

/**
* Return the next value in history after undoing previous changes
* @returns A copy of the next value in history, or `undefined` if there are no changes
*/
redo = (): T | undefined =>
this.historyIndex + 1 < this.history.length ? _cloneDeep(this.history[++this.historyIndex]) : undefined;

/**
* Get a current value of the object
* @returns A copy of the current value of the object
*/
current = (): T => _cloneDeep(this.history[this.historyIndex]);

/**
* Reset the data to its initial state. This adds the initial data to the
* history such that it is still possible to undo the reset
* @returns The initial data
*/
reset = (): T => {
this.record(this.initialData);
return this.initialData;
};

/**
* Save a new current value for the object.
* @param newData The new current value of the object
*/
record = (newData: T) => {
const undoCount = this.maxCount;

// Remove all future history if we are not at the end of the history
// (i.e. if we have undone some changes)
if (this.historyIndex + 1 < this.history.length) {
this.history.splice(this.historyIndex + 1, this.history.length);
}

// Add the new data to the history, cloning the object to freeze it
this.history.push(_cloneDeep(newData));
this.historyIndex++;

// If we have reached the maximum number of changes, remove the oldest
// changes from the history
while (this.history.length > undoCount) {
this.history.shift();
this.historyIndex--;
}
};

/**
* Return whether the object has changed. If an attribute is specified, it
* will return whether this attribute has changed from its initial value
* @param attribute An optional attribute to check for changes
* @returns Whether the object, or one of its attributes has changed since
* the initial state
*/
hasChanged(attribute?: keyof T) {
if (attribute === undefined) {
return !_isEqual(this.current(), this.initialData);
} else {
return !_isEqual(this.current()[attribute], this.initialData[attribute]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright 2025, Polytechnique Montreal and contributors
*
* This file is licensed under the MIT License.
* License text available at https://opensource.org/licenses/MIT
*/
import { HistoryTracker } from '../HistoryTracker';

type TestType = {
id: number;
value: number;
optionalField?: string;
}

describe('HistoryTracker', () => {
let tracker: HistoryTracker<TestType>;
const initialData: TestType = { id: 1, value: 10 };

beforeEach(() => {
tracker = new HistoryTracker(initialData);
});

test('should initialize with original data', () => {
expect(tracker.current()).toEqual(initialData);
});

test('should have no possible changes when no changes recorded', () => {
expect(tracker.canUndo()).toEqual(false);
expect(tracker.canRedo()).toEqual(false);
expect(tracker.hasChanged()).toEqual(false);
expect(tracker.hasChanged('value')).toEqual(false);
expect(tracker.undo()).toBeUndefined();
expect(tracker.redo()).toBeUndefined();
})

test('should record changes and undo them', () => {
const newData: TestType = { id: 1, value: 20 };
tracker.record(newData);
expect(tracker.current()).toEqual(newData);

expect(tracker.canUndo()).toEqual(true);
expect(tracker.canRedo()).toEqual(false);
const undoneData = tracker.undo();
expect(undoneData).toEqual(initialData);
expect(tracker.current()).toEqual(initialData);
});

test('should redo changes after undo', () => {
const newData: TestType = { id: 1, value: 20 };
tracker.record(newData);
tracker.undo();

expect(tracker.canUndo()).toEqual(false);
expect(tracker.canRedo()).toEqual(true);
const redoneData = tracker.redo();
expect(redoneData).toEqual(newData);
expect(tracker.current()).toEqual(newData);
});

test('should not undo past the initial state', () => {
tracker.undo();
expect(tracker.current()).toEqual(initialData);
});

test('should not redo past the latest state', () => {
const newData: TestType = { id: 1, value: 20 };
tracker.record(newData);
tracker.redo();
expect(tracker.current()).toEqual(newData);
});

test('should undo additional of an optional fields', () => {
// Update an optional field
const newDataWithOptionalField: TestType = { ...initialData, optionalField: 'test' };
tracker.record(newDataWithOptionalField);
tracker.undo();

expect(tracker.current()).toEqual({ ...initialData, optionalField: undefined });
});
Comment on lines +72 to +79
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate the test file
find . -name "HistoryTracker.test.ts" -type f

Repository: chairemobilite/transition

Length of output: 148


🏁 Script executed:

#!/bin/bash
# Read the test file to understand the structure
cat -n packages/chaire-lib-common/src/utils/objects/__tests__/HistoryTracker.test.ts | head -100

Repository: chairemobilite/transition

Length of output: 4265


🏁 Script executed:

#!/bin/bash
# Find and examine the HistoryTracker implementation
find . -name "HistoryTracker.ts" -type f | grep -v test

Repository: chairemobilite/transition

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Read the HistoryTracker implementation
cat -n packages/chaire-lib-common/src/utils/objects/HistoryTracker.ts

Repository: chairemobilite/transition

Length of output: 4649


Fix the expected value in the optional field undo test.

Line 78 expects { ...initialData, optionalField: undefined }, but this explicitly includes an optionalField key that doesn't exist in initialData. Undoing should restore the exact initial state { id: 1, value: 10 } without the optionalField key. Change the expectation to initialData.

🤖 Prompt for AI Agents
In
@packages/chaire-lib-common/src/utils/objects/__tests__/HistoryTracker.test.ts
around lines 72 - 79, The test 'should undo additional of an optional fields'
currently expects { ...initialData, optionalField: undefined } which incorrectly
retains the optionalField key; update the assertion to expect the original
initialData instead (use initialData) so that after tracker.record(...) and
tracker.undo() the call to tracker.current() is compared to the exact initial
state (initialData), ensuring the optional field is removed.


test('should redo removal of an optional fields', () => {
// Record a change with the optional field set
const newDataWithOptionalField: TestType = { ...initialData, optionalField: 'test' };
tracker.record(newDataWithOptionalField);
// Reset the optional field to undefined
tracker.record(initialData);

// Undo and make sure the optional field is set
tracker.undo();
expect(tracker.current()).toEqual(newDataWithOptionalField);

// Redo and make sure the optional field is removed
tracker.redo();
expect(tracker.current()).toEqual(initialData);
});

test('should reset to original data', () => {
const newData: TestType = { id: 1, value: 20 };
tracker.record(newData);
const resetData = tracker.reset();
expect(tracker.current()).toEqual(initialData);
expect(resetData).toEqual(initialData);

// Make sure reset can be undone
const undoneData = tracker.undo();
expect(undoneData).toEqual(newData);
});

test('should reset future history with new changes after undo', () => {
// Add 3 elements
for (let i = 1; i <= 3; i++) {
tracker.record({ id: 1, value: 10 + i });
}
// Undo 2 of them
tracker.undo();
tracker.undo();
expect(tracker.canRedo()).toEqual(true);

// Record a new change
tracker.record({ id: 1, value: 20 });
expect(tracker.canRedo()).toEqual(false);
})

test('should limit history to maxCount', () => {
const maxCount = 5;
tracker = new HistoryTracker(initialData, maxCount);

for (let i = 1; i <= maxCount + 2; i++) {
tracker.record({ id: 1, value: 10 + i });
}

expect(tracker.canUndo()).toBe(true);
for (let i = 0; i < maxCount; i++) {
tracker.undo();
}
expect(tracker.canUndo()).toBe(false);
expect(tracker.current()).toEqual({ id: 1, value: 13 });
});

test('should detect changes', () => {
const newData: TestType = { id: 1, value: 20 };
expect(tracker.hasChanged()).toBe(false);
tracker.record(newData);
expect(tracker.hasChanged()).toBe(true);
});

test('should detect attribute changes', () => {
const newData: TestType = { id: 1, value: 20 };
expect(tracker.hasChanged('value')).toBe(false);
tracker.record(newData);
expect(tracker.hasChanged('value')).toBe(true);
expect(tracker.hasChanged('id')).toBe(false);
});

test('should have a copy of the object to record', () => {
const newData: TestType = { id: 1, value: 20 };
tracker.record(newData);
// Change the data
newData.value = 30;
expect(tracker.current()).toEqual({ id: 1, value: 20 });
});
});
Loading
Loading