-
Notifications
You must be signed in to change notification settings - Fork 20
objects: add a history tracker and have forms use the useHistoryTracker hook
#1565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, locate the test file
find . -name "HistoryTracker.test.ts" -type fRepository: 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 -100Repository: chairemobilite/transition Length of output: 4265 🏁 Script executed: #!/bin/bash
# Find and examine the HistoryTracker implementation
find . -name "HistoryTracker.ts" -type f | grep -v testRepository: chairemobilite/transition Length of output: 133 🏁 Script executed: #!/bin/bash
# Read the HistoryTracker implementation
cat -n packages/chaire-lib-common/src/utils/objects/HistoryTracker.tsRepository: chairemobilite/transition Length of output: 4649 Fix the expected value in the optional field undo test. Line 78 expects 🤖 Prompt for AI Agents |
||
|
|
||
| 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 }); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.