diff --git a/packages/chaire-lib-common/src/utils/objects/HistoryTracker.ts b/packages/chaire-lib-common/src/utils/objects/HistoryTracker.ts new file mode 100644 index 000000000..c835ee6cb --- /dev/null +++ b/packages/chaire-lib-common/src/utils/objects/HistoryTracker.ts @@ -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> { + 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]); + } + } +} diff --git a/packages/chaire-lib-common/src/utils/objects/__tests__/HistoryTracker.test.ts b/packages/chaire-lib-common/src/utils/objects/__tests__/HistoryTracker.test.ts new file mode 100644 index 000000000..ec302f265 --- /dev/null +++ b/packages/chaire-lib-common/src/utils/objects/__tests__/HistoryTracker.test.ts @@ -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; + 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 }); + }); + + 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 }); + }); +}); diff --git a/packages/chaire-lib-frontend/src/components/forms/__tests__/useHistoryTracker.test.tsx b/packages/chaire-lib-frontend/src/components/forms/__tests__/useHistoryTracker.test.tsx new file mode 100644 index 000000000..771633a3a --- /dev/null +++ b/packages/chaire-lib-frontend/src/components/forms/__tests__/useHistoryTracker.test.tsx @@ -0,0 +1,501 @@ +/* + * Copyright 2025, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { renderHook, act } from '@testing-library/react'; + +import { useHistoryTracker } from '../useHistoryTracker'; +import { BaseObject } from 'chaire-lib-common/lib/utils/objects/BaseObject'; + +type TestAttributes = { + id?: number; // Optional ID field + field1: string; + field2?: number; +}; + +class TestObject extends BaseObject { + protected _validate(): [boolean, string[]] { + return [true, []]; + } + protected _prepareAttributes(attributes: Partial): TestAttributes { + return { + field1: '', + ...attributes + } + } + get id() { + return this.attributes.id; + } +}; + +const defaultField1 = 'test'; +const defaultField2 = undefined; +const defaultAttributes: TestAttributes = { + field1: defaultField1 +}; + +describe('useHistoryTracker hook', () => { + let initialObject: TestObject; + + beforeEach(() => { + initialObject = new TestObject(defaultAttributes); + }); + + test('Update field1 with valid value', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange + act(() => { + result.current.onValueChange('field1', { value: 'new value', valid: true }); + }); + + // Check updated value + expect(initialObject.attributes.field1).toBe('new value'); + }); + + test('Update field1 with invalid value', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange + act(() => { + result.current.onValueChange('field1', { value: 'new value', valid: false }); + }); + + // Check updated value + expect(initialObject.attributes.field1).toBe('test'); + }); + + test('Cannot undo/redo when no changes', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Should return false for undo/redo + expect(result.current.canUndo()).toBe(false); + expect(result.current.canRedo()).toBe(false); + + // Undo/redo should have no effect + act(() => { + result.current.undo(); + }); + expect(initialObject.attributes.field1).toBe('test'); + act(() => { + result.current.redo(); + }); + expect(initialObject.attributes.field1).toBe('test'); + }); + + test('Should undo previous changes', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + result.current.updateHistory(); + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + result.current.updateHistory(); + }); + + // Should be able to undo + expect(result.current.canUndo()).toBe(true); + + // Undo last change + let undoneObject: TestObject | undefined = undefined; + act(() => { + undoneObject = result.current.undo(); + }); + expect(undoneObject!.attributes.field1).toBe(updatedValue1); + + // Should still be able to undo + expect(result.current.canUndo()).toBe(true); + + // Undo another change + act(() => { + undoneObject = result.current.undo(); + }); + expect(undoneObject!.attributes.field1).toBe(defaultField1); + + // No more undo + expect(result.current.canUndo()).toBe(false); + }); + + test('Should redo undone changes', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + result.current.updateHistory(); + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + result.current.updateHistory(); + }); + + // Undo twice + let changedObject: TestObject | undefined = undefined; + act(() => { + result.current.undo(); + changedObject = result.current.undo(); + }); + + // Should be able to redo + expect(result.current.canRedo()).toBe(true); + + // Redo last change + act(() => { + changedObject = result.current.redo(); + }); + expect(changedObject!.attributes.field1).toBe(updatedValue1); + + // Should still be able to redo + expect(result.current.canRedo()).toBe(true); + + // Redo another change + act(() => { + changedObject = result.current.redo(); + }); + expect(changedObject!.attributes.field1).toBe(updatedValue2); + + // No more redo + expect(result.current.canRedo()).toBe(false); + }); + + test('Should undo/redo atomically changes of multiple values', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange for 2 fields, and update the history only after the second change + const updatedField1 = 'new value 1'; + const updatedField2 = 3; + act(() => { + result.current.onValueChange('field1', { value: updatedField1, valid: true }); + result.current.onValueChange('field2', { value: updatedField2, valid: true }); + result.current.updateHistory(); + }); + + // Check values of object + expect(initialObject.attributes.field1).toBe(updatedField1); + expect(initialObject.attributes.field2).toBe(updatedField2); + + // Should be able to undo + expect(result.current.canUndo()).toBe(true); + expect(result.current.canRedo()).toBe(false); + + // Undo last change + let changedObject: TestObject | undefined = undefined; + act(() => { + changedObject = result.current.undo(); + }); + expect(changedObject!.attributes.field1).toBe(defaultField1); + expect(changedObject!.attributes.field2).toBe(defaultField2); + + // No more undo + expect(result.current.canUndo()).toBe(false); + expect(result.current.canRedo()).toBe(true); + + // Redo last change + act(() => { + changedObject = result.current.redo(); + }); + expect(changedObject!.attributes.field1).toBe(updatedField1); + expect(changedObject!.attributes.field2).toBe(updatedField2); + + // No more redo + expect(result.current.canRedo()).toBe(false); + + }); + + test('Should undo/redo previous changes of invalid values', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial values of object and form values + expect(initialObject.attributes.field1).toEqual(defaultField1); + expect(result.current.formValues['field1']).toEqual(defaultField1); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: false }); + }); + act(() => { + result.current.updateHistory(); + }); + + // Check object has not been updated, but form field has been + expect(initialObject.attributes.field1).toBe(defaultField1); + expect(result.current.formValues['field1']).toEqual(updatedValue1); + + // Should be able to undo + expect(result.current.canUndo()).toBe(true); + + // Undo last change + let undoneObject: TestObject | undefined = undefined; + act(() => { + undoneObject = result.current.undo(); + }); + // Check object has not been updated, but form field has been reverted + expect(undoneObject!.attributes.field1).toBe(defaultField1); + expect(result.current.formValues['field1']).toEqual(defaultField1); + + // Should not be able to undo anymore, but should be able to redo + expect(result.current.canUndo()).toBe(false); + expect(result.current.canRedo()).toBe(true); + + // Undo last change + let redoneObject: TestObject | undefined = undefined; + act(() => { + redoneObject = result.current.redo(); + }); + // Check object has not been updated, but form field has been updated again + expect(redoneObject!.attributes.field1).toBe(defaultField1); + expect(result.current.formValues['field1']).toEqual(updatedValue1); + }); + + test('Update field1 with valid value and check invalid fields', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Call onValueChange + act(() => { + result.current.onValueChange('field1', { value: 'new value', valid: true }); + }); + + // Check that there are no invalid fields + expect(result.current.hasInvalidFields()).toBe(false); + }); + + test('Update field1 with invalid value and check invalid fields', () => { + const { result } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Call onValueChange with invalid value + act(() => { + result.current.onValueChange('field1', { value: 'new value', valid: false }); + }); + + // Check that there are invalid fields + expect(result.current.hasInvalidFields()).toBe(true); + }); + + test('Should use same history tracker when re-rendering with same object without ID', () => { + const { result, rerender } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + act(() => { + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + + // Re-render with same object + rerender({ object: initialObject }); + + // Should be able to undo + expect(result.current.canUndo()).toBe(true); + + // Re-render with another object, but with same id, should still keep history + const newObjectSameId = new TestObject({ field1: 'new value for field1' }); + rerender({ object: newObjectSameId }); + expect(result.current.canUndo()).toBe(true); + }); + + test('Should use same history tracker when re-rendering with object with same ID', () => { + const objectId = 1; + const testObjectWithId = new TestObject({ ...defaultAttributes, id: objectId }); + const { result, rerender } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: testObjectWithId } }); + + // Check initial value + expect(testObjectWithId.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + act(() => { + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + + // Re-render with same object + rerender({ object: testObjectWithId }); + + // Should be able to undo + expect(result.current.canUndo()).toBe(true); + + // Re-render with another object, but with same id, should still keep history + const newObjectSameId = new TestObject({ field1: 'new value for field1', id: objectId }); + rerender({ object: newObjectSameId }); + expect(result.current.canUndo()).toBe(true); + }); + + test('Should use same history tracker and follow history after undoing/redoing ', () => { + const objectId = 1; + let objectChangeCount = 0; + const testObjectWithId = new TestObject({ ...defaultAttributes, id: objectId }); + const { result, rerender } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: testObjectWithId, changeCount: objectChangeCount } }); + + // Check initial value + expect(testObjectWithId.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + result.current.updateHistory(); + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + result.current.updateHistory(); + }); + + // Re-render with same object + rerender({ object: testObjectWithId, changeCount: objectChangeCount++ }); + + // Undo last change + let changedObject: TestObject | undefined = undefined; + act(() => { + changedObject = result.current.undo(); + }); + expect(changedObject!.attributes.field1).toBe(updatedValue1); + expect(changedObject!.attributes.field2).toBe(defaultField2); + + // Re-render with changed object + rerender({ object: changedObject!, changeCount: objectChangeCount++ }); + + // Make a new change to each field + const updatedValue3 = 'new value 3'; + const updatedValue4 = 3; + act(() => { + result.current.onValueChange('field1', { value: updatedValue3, valid: true }); + result.current.updateHistory(); + result.current.onValueChange('field2', { value: updatedValue4, valid: true }); + result.current.updateHistory(); + }); + + // Undo last change + act(() => { + changedObject = result.current.undo(); + }); + expect(changedObject!.attributes.field1).toBe(updatedValue3); + expect(changedObject!.attributes.field2).toBe(defaultField2); + + rerender({ object: changedObject!, changeCount: objectChangeCount++ }); + + // Make 2 changes to field1 again + act(() => { + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + result.current.updateHistory(); + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + result.current.updateHistory(); + }); + + // Undo change. If the object reference did not update correctly, the resulting object will not be correct + act(() => { + changedObject = result.current.undo(); + }); + expect(changedObject!.attributes.field1).toBe(updatedValue2); + expect(changedObject!.attributes.field2).toBe(defaultField2); + + rerender({ object: changedObject!, changeCount: objectChangeCount++ }); + expect(result.current.canUndo()).toBe(true); + }); + + test('Should use new history tracker when re-rendering an object with different ID', () => { + const objectId = 1; + const testObjectWithId = new TestObject({ ...defaultAttributes, id: objectId }); + const { result, rerender } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: testObjectWithId } }); + + // Check initial value + expect(testObjectWithId.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + act(() => { + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + // Make sure we can undo with current object + expect(result.current.canUndo()).toBe(true); + + // Re-render with an object with different ID + const newObject = new TestObject({ ...defaultAttributes, id: objectId + 1 }); + rerender({ object: newObject }); + expect(result.current.canUndo()).toBe(false); + }); + + test('Should use new history tracker when an object without ID now has one', () => { + // Start with the initial object without ID + const { result, rerender } = renderHook((props) => useHistoryTracker(props), { initialProps: { object: initialObject } }); + + // Check initial value + expect(initialObject.attributes.field1).toBe('test'); + + // Call onValueChange twice, with an history update + const updatedValue1 = 'new value 1'; + const updatedValue2 = 'new value 2'; + act(() => { + result.current.onValueChange('field1', { value: updatedValue1, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + act(() => { + result.current.onValueChange('field1', { value: updatedValue2, valid: true }); + }); + act(() => { + result.current.updateHistory(); + }); + // Make sure we can undo with current object + expect(result.current.canUndo()).toBe(true); + + // Re-render with the same object with its ID set + initialObject.set('id', 3); + rerender({ object: initialObject }); + expect(result.current.canUndo()).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/chaire-lib-frontend/src/components/forms/useHistoryTracker.tsx b/packages/chaire-lib-frontend/src/components/forms/useHistoryTracker.tsx new file mode 100644 index 000000000..42aa2a7bc --- /dev/null +++ b/packages/chaire-lib-frontend/src/components/forms/useHistoryTracker.tsx @@ -0,0 +1,237 @@ +/* + * Copyright 2025, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { useState, useCallback, useEffect, useRef } from 'react'; +import _cloneDeep from 'lodash/cloneDeep'; +import _isEqual from 'lodash/isEqual'; + +import { BaseObject } from 'chaire-lib-common/lib/utils/objects/BaseObject'; +import { HistoryTracker } from 'chaire-lib-common/lib/utils/objects/HistoryTracker'; + +export type WithHistoryTracker>> = { + /** + * Callback to call when the value of an object's attribute is changed. If + * the value is valid, it will mutate the object, by calling the `set` + * function, but it will not track the change. If the value is invalid, it + * will keep the values of the form field, without updating the object. To + * track the change, the `updateHistory` method should be called when all + * changes and side-effects are completed. + * @param path The path of the attribute to change + * @param newValue The value of the attribute and whether this value is + * valid + */ + onValueChange: ( + path: K, + newValue: { value?: T['attributes'][K] | null; valid?: boolean } + ) => void; + + /** + * Return whether there are changes to undo + * @returns `true` if there are changes that can be undone + */ + canUndo: () => boolean; + /** + * Undo the last change on the object. It creates a new object with the + * undone attributes. + */ + undo: () => T | undefined; + /** + * Return whether there are changes to redo + * @returns `true` if there are changes that can be redone + */ + canRedo: () => boolean; + /** + * Redo the last change on the object. It returns a new object with the + * redone attributes. + */ + redo: () => T | undefined; + /** + * Return whether there are invalid fields in the form + * @returns `true` if there are invalid field values in the form + */ + hasInvalidFields: () => boolean; + /** + * Record the current state of the object in the history tracker. Sometimes, + * a change of values has side effects on the object that updates other of + * its field, so to make all those changes atomic, we need to be able to + * decide when to record the object. + * @returns + */ + updateHistory: () => void; + /** + * Keep track of current form values, this is useful to revert the form as + * well as the object's attributes. + */ + formValues: { [key: string]: any }; +}; + +/** + * Hook that tracks changes on an object and allows to undo/redo them. It also + * track changes to the form values, which may be invalid and not necessarily + * result in changes to the object itself. These can also be undone/redone. + * + * FIXME Note that some fields, like InputStringFormatted do not send the + * updated invalid values upon update, just the fact that the field is invalid, + * so those fields will not be tracked here, though the result of the invalidity + * will. Either find a way to send the invalid values so it can be properly + * tracked, or decide not the track invalid values at all. + * + * FIXME We may not want to keep track of changes to invalid fields, maybe we + * can just keep track of those in the state, only record if object attributes + * changed, and when undoing, if the current state matches the last saved record + * and if not, instead of undoing, just update to the last good record. This + * would need to be tested to see if it works well, especially with multiple + * invalid fields, with some valid updates in between. + * + * @param object The object to track changes on + * @returns + */ +export const useHistoryTracker = >({ object }: { object: T }): WithHistoryTracker => { + // Keep a state for rendering purposes + // FIXME, like invalidFieldsCount below, we might want to keep a count instead of the whole object + const [formValues, setFormValues] = useState<{ [key: string]: any }>(_cloneDeep(object.attributes)); + // Use a ref for immediate access to current values + const formValuesRef = useRef<{ [key: string]: any }>(_cloneDeep(object.attributes)); + // Keep ref of invalid fields to track changes + const invalidFieldsRef = useRef>>({}); + // Keep a state for re-render triggering when invalidFields change + const [invalidFieldsCount, setInvalidFieldsCount] = useState(0); + + const [historyTracker, setHistoryTracker] = useState( + new HistoryTracker({ + attributes: object.attributes, + formValues: _cloneDeep(object.attributes), + invalidFields: {} + }) + ); + + // Reset the history tracker and invalid fields when the object is a new + // one. Only re-run the effect if the object's id changed. + useEffect(() => { + const trackedAttributes = { + attributes: object.attributes, + formValues: _cloneDeep(object.attributes), + invalidFields: {} + }; + // Create a new history tracker and reset fields when the object id changes + const newHistoryTracker = new HistoryTracker(trackedAttributes); + setHistoryTracker(newHistoryTracker); + + // Reset the invalidFields ref when object ID changes + invalidFieldsRef.current = {}; + setInvalidFieldsCount(0); + + // Also reset the ref when object ID changes + formValuesRef.current = _cloneDeep(object.attributes); + setFormValues(_cloneDeep(object.attributes)); + }, [(object as any).id]); + + const onValueChange = useCallback( + ( + path: K, + newValue: { value?: T['attributes'][K] | null; valid?: boolean } = { value: null, valid: true } + ) => { + // Update form values in both ref (immediately) and state (for rendering) + formValuesRef.current = { + ...formValuesRef.current, + [path]: newValue.value + }; + + // Update state to trigger re-render + setFormValues(formValuesRef.current); + + // Update invalid fields status in the ref + const currentInvalidValue = !!invalidFieldsRef.current[path]; + const isFieldInvalid = newValue.valid !== undefined && !newValue.valid; + invalidFieldsRef.current[path] = isFieldInvalid; + + // Only trigger re-render if the field validity changes + if (currentInvalidValue && !isFieldInvalid) { + setInvalidFieldsCount((count) => count - 1); + } else if (!currentInvalidValue && isFieldInvalid) { + setInvalidFieldsCount((count) => count + 1); + } + // Only update the object if the value is valid + if (!isFieldInvalid) { + object.set(path as keyof T['attributes'], newValue.value); + } + }, + [object] + ); + + const hasInvalidFields = useCallback((): boolean => { + return invalidFieldsCount > 0; + }, [invalidFieldsCount]); // Depend on count for re-rendering + + // Update the history if necessary + const updateHistory = useCallback(() => { + const newValues = { + attributes: object.attributes, + formValues: formValuesRef.current, // Always has the latest values + invalidFields: { ...invalidFieldsRef.current } // Add invalid fields + }; + if (!_isEqual(newValues, historyTracker.current())) { + historyTracker.record(newValues); + } + }, [historyTracker, object]); + + const undo = useCallback(() => { + const undoneData = historyTracker.undo(); + if (undoneData !== undefined) { + const { + attributes: undoneAttributes, + formValues: undoneFormValues, + invalidFields: undoneInvalidFields + } = undoneData; + + // Update both ref and state + formValuesRef.current = undoneFormValues; + setFormValues(undoneFormValues); + // Reset invalid fields + invalidFieldsRef.current = { ...undoneInvalidFields }; + setInvalidFieldsCount(Object.keys(undoneInvalidFields).filter((key) => undoneInvalidFields[key]).length); + + return new (object.constructor as new (attributes: Partial) => T)(undoneAttributes); + } + return undefined; + }, [historyTracker]); + + const canUndo = useCallback(() => historyTracker.canUndo(), [historyTracker]); + + const redo = useCallback(() => { + const redoneData = historyTracker.redo(); + if (redoneData !== undefined) { + const { + attributes: redoneAttributes, + formValues: redoneFormValues, + invalidFields: redoneInvalidFields + } = redoneData; + + // Update both ref and state + formValuesRef.current = redoneFormValues; + setFormValues(redoneFormValues); + // Reset invalid fields + invalidFieldsRef.current = { ...redoneInvalidFields }; + setInvalidFieldsCount(Object.keys(redoneInvalidFields).filter((key) => redoneInvalidFields[key]).length); + + return new (object.constructor as new (attributes: Partial) => T)(redoneAttributes); + } + return undefined; + }, [historyTracker]); + + const canRedo = useCallback(() => historyTracker.canRedo(), [historyTracker]); + + return { + onValueChange, + canUndo, + undo, + canRedo, + redo, + hasInvalidFields, + updateHistory, + formValues // Return the state version for rendering + }; +}; diff --git a/packages/chaire-lib-frontend/src/components/pageParts/UndoRedoButtons.tsx b/packages/chaire-lib-frontend/src/components/pageParts/UndoRedoButtons.tsx new file mode 100644 index 000000000..c6ee26cc8 --- /dev/null +++ b/packages/chaire-lib-frontend/src/components/pageParts/UndoRedoButtons.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2025, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { faUndoAlt } from '@fortawesome/free-solid-svg-icons/faUndoAlt'; +import { faRedoAlt } from '@fortawesome/free-solid-svg-icons/faRedoAlt'; + +import Button from '../input/Button'; + +export type UndoRedoButtonsProps = { + /** + * Function called after the undo button was clicked and the last action was + * undone + */ + onUndo: () => void; + /** + * Function called after the redo button was clicked and the last action was + * redone + */ + onRedo: () => void; + canRedo: () => boolean; + canUndo: () => boolean; +}; + +const UndoRedoButtons: React.FunctionComponent = (props: UndoRedoButtonsProps) => { + const { t } = useTranslation(['main', 'notifications']); + + return ( +
+
+ ); +}; + +export default UndoRedoButtons; diff --git a/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingForm.tsx b/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingForm.tsx index 68e210e56..1f676e776 100644 --- a/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingForm.tsx +++ b/packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingForm.tsx @@ -4,7 +4,7 @@ * This file is licensed under the MIT License. * License text available at https://opensource.org/licenses/MIT */ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import Collapsible from 'react-collapsible'; import { useTranslation } from 'react-i18next'; import { faCheckCircle } from '@fortawesome/free-solid-svg-icons/faCheckCircle'; @@ -36,85 +36,97 @@ import { EventManager } from 'chaire-lib-common/lib/services/events/EventManager import { MapUpdateLayerEventType } from 'chaire-lib-frontend/lib/services/map/events/MapEventsCallbacks'; import { calculateRouting } from '../../../services/routing/RoutingUtils'; import { RoutingResultsByMode } from 'chaire-lib-common/lib/services/routing/types'; +import { useHistoryTracker } from 'chaire-lib-frontend/lib/components/forms/useHistoryTracker'; +import UndoRedoButtons from 'chaire-lib-frontend/lib/components/pageParts/UndoRedoButtons'; export interface TransitRoutingFormProps { availableRoutingModes?: string[]; } -const TransitRoutingForm: React.FC = (props) => { +const TransitRoutingForm: React.FC = (props: TransitRoutingFormProps) => { // State hooks to replace class state - const transitRouting = useRef( + const transitRoutingRef = useRef( new TransitRouting(_cloneDeep(Preferences.get('transit.routing.transit'))) - ).current; + ); + const transitRouting = transitRoutingRef.current; // State value is not used const [, setRoutingAttributes] = useState(transitRouting.attributes); - // FIXME using any to avoid typing the formValues, which would be tedious, will be rewritten soon anyway - const [formValues, setFormValues] = useState(() => ({ - routingName: transitRouting.attributes.routingName || '', - routingModes: transitRouting.attributes.routingModes || ['transit'], - minWaitingTimeSeconds: transitRouting.attributes.minWaitingTimeSeconds, - maxAccessEgressTravelTimeSeconds: transitRouting.attributes.maxAccessEgressTravelTimeSeconds, - maxTransferTravelTimeSeconds: transitRouting.attributes.maxTransferTravelTimeSeconds, - maxFirstWaitingTimeSeconds: transitRouting.attributes.maxFirstWaitingTimeSeconds, - maxTotalTravelTimeSeconds: transitRouting.attributes.maxTotalTravelTimeSeconds, - scenarioId: transitRouting.attributes.scenarioId, - withAlternatives: transitRouting.attributes.withAlternatives - })); - const [currentResult, setCurrentResult] = useState(undefined); const [scenarioCollection, setScenarioCollection] = useState(serviceLocator.collectionManager.get('scenarios')); const [loading, setLoading] = useState(false); const [routingErrors, setRoutingErrors] = useState(undefined); const [selectedMode, setSelectedMode] = useState(undefined); + const [changeCount, setChangeCount] = useState(0); // Used to force a rerender when the object changes const { t } = useTranslation(['transit', 'main', 'form']); // Using refs for stateful values that don't trigger renders - const invalidFieldsRef = useRef<{ [key: string]: boolean }>({}); const calculateRoutingNonceRef = useRef(new Object()); - // Functionality from ChangeEventsForm - const hasInvalidFields = (): boolean => { - return Object.keys(invalidFieldsRef.current).filter((key) => invalidFieldsRef.current[key]).length > 0; - }; + const { + onValueChange: onFieldValueChange, + hasInvalidFields, + formValues, + updateHistory, + canRedo, + canUndo, + undo, + redo + } = useHistoryTracker({ object: transitRouting }); + + // Update scenario collection when it changes + const onScenarioCollectionUpdate = useCallback(() => { + setScenarioCollection(serviceLocator.collectionManager.get('scenarios')); + }, []); - const onFormFieldChange = ( - path: string, - newValue: { value: any; valid?: boolean } = { value: null, valid: true } - ) => { - setFormValues((prevValues) => ({ ...prevValues, [path]: newValue.value })); - if (newValue.valid !== undefined && !newValue.valid) { - invalidFieldsRef.current[path] = true; - } else { - invalidFieldsRef.current[path] = false; - } - }; + // Setup event listeners on mount and cleanup on unmount + useEffect(() => { + serviceLocator.eventManager.on('collection.update.scenarios', onScenarioCollectionUpdate); - const onValueChange = ( - path: keyof TransitRoutingAttributes, - newValue: { value: any; valid?: boolean } = { value: null, valid: true }, - resetResults = true - ) => { - setRoutingErrors([]); //When a value is changed, remove the current routingErrors to stop displaying them. - onFormFieldChange(path, newValue); - if (newValue.valid || newValue.valid === undefined) { - const updatedObject = transitRouting; - updatedObject.set(path, newValue.value); - setRoutingAttributes({ ...updatedObject.attributes }); - } + return () => { + serviceLocator.eventManager.off('collection.update.scenarios', onScenarioCollectionUpdate); + }; + }, [onScenarioCollectionUpdate]); - if (resetResults) { - resetResultsData(); + // Setup event listeners on mount and cleanup on unmount + useEffect(() => { + if (transitRouting.hasOrigin()) { + (serviceLocator.eventManager as EventManager).emitEvent('map.updateLayer', { + layerName: 'routingPoints', + data: transitRouting.originDestinationToGeojson() + }); } - }; + }, [changeCount]); - const resetResultsData = () => { + const resetResultsData = useCallback(() => { setCurrentResult(undefined); serviceLocator.eventManager.emit('map.updateLayers', { routingPaths: undefined, routingPathsStrokes: undefined }); - }; + }, []); + + const onValueChange = useCallback( + ( + path: keyof TransitRoutingAttributes, + newValue: { value: any; valid?: boolean } = { value: null, valid: true }, + resetResults = true + ) => { + setRoutingErrors([]); //When a value is changed, remove the current routingErrors to stop displaying them. + onFieldValueChange(path, newValue); + if (newValue.valid || newValue.valid === undefined) { + const updatedObject = transitRouting; + updatedObject.set(path, newValue.value); + setRoutingAttributes({ ...updatedObject.attributes }); + } + + if (resetResults) { + resetResultsData(); + } + updateHistory(); + }, + [onFieldValueChange, resetResultsData, transitRouting, updateHistory] + ); const isValid = (): boolean => { // Are all form fields valid and the routing object too @@ -129,11 +141,11 @@ const TransitRoutingForm: React.FC = (props) => { originGeojson, destinationGeojson } = routing.attributes; + if (!originGeojson || !destinationGeojson) { return; } - // Save the origin et destinations lat/lon, and time, along with whether it is arrival or departure - // TODO Support specifying departure/arrival as variable in batch routing + routing.addElementForBatch({ routingName, departureTimeSecondsSinceMidnight, @@ -144,10 +156,6 @@ const TransitRoutingForm: React.FC = (props) => { routing.set('routingName', ''); // empty routing name for the next route setRoutingAttributes({ ...routing.attributes }); - setFormValues((prevValues) => ({ - ...prevValues, - routingName: routing.attributes.routingName - })); }; const calculate = async (refresh = false) => { @@ -213,20 +221,19 @@ const TransitRoutingForm: React.FC = (props) => { } setRoutingAttributes({ ...routing.attributes }); setCurrentResult(undefined); - }; - - const onScenarioCollectionUpdate = () => { - setScenarioCollection(serviceLocator.collectionManager.get('scenarios')); + updateHistory(); }; const downloadCsv = () => { const elements = transitRouting.attributes.savedForBatch; const lines: string[] = []; lines.push('id,routingName,originLon,originLat,destinationLon,destinationLat,time'); + elements.forEach((element, index) => { const time = !_isBlank(element.arrivalTimeSecondsSinceMidnight) ? element.arrivalTimeSecondsSinceMidnight : element.departureTimeSecondsSinceMidnight; + lines.push( index + ',' + @@ -243,6 +250,7 @@ const TransitRoutingForm: React.FC = (props) => { (!_isBlank(time) ? secondsSinceMidnightToTimeStr(time as number) : '') ); }); + const csvFileContent = lines.join('\n'); const element = document.createElement('a'); @@ -257,32 +265,18 @@ const TransitRoutingForm: React.FC = (props) => { const updatedObject = transitRouting; updatedObject.resetBatchSelection(); setRoutingAttributes({ ...updatedObject.attributes }); + updateHistory(); }; - const onTripTimeChange = (time: { value: any; valid?: boolean }, timeType: 'departure' | 'arrival') => { - onValueChange( - timeType === 'departure' ? 'departureTimeSecondsSinceMidnight' : 'arrivalTimeSecondsSinceMidnight', - time - ); - }; - - // Handle componentDidMount and componentWillUnmount - useEffect(() => { - // ComponentDidMount - if (transitRouting.hasOrigin()) { - (serviceLocator.eventManager as EventManager).emitEvent('map.updateLayer', { - layerName: 'routingPoints', - data: transitRouting.originDestinationToGeojson() - }); - } - - serviceLocator.eventManager.on('collection.update.scenarios', onScenarioCollectionUpdate); - - // ComponentWillUnmount - return () => { - serviceLocator.eventManager.off('collection.update.scenarios', onScenarioCollectionUpdate); - }; - }, []); + const onTripTimeChange = useCallback( + (time: { value: any; valid?: boolean }, timeType: 'departure' | 'arrival') => { + onValueChange( + timeType === 'departure' ? 'departureTimeSecondsSinceMidnight' : 'arrivalTimeSecondsSinceMidnight', + time + ); + }, + [onValueChange] + ); // If the previously selected scenario was deleted, the current scenario ID will remain but the scenario itself will no longer exist, leading to an error. // In that case, change it to undefined. @@ -319,6 +313,31 @@ const TransitRoutingForm: React.FC = (props) => { }; }); + const updateCurrentObject = (newObject: TransitRouting) => { + transitRoutingRef.current = newObject; + resetResultsData(); + setChangeCount(changeCount + 1); + // Update routing preferences if the object is valid. + // FIXME Should we calculate too? + if (isValid()) { + newObject.updateRoutingPrefs(); + } + }; + + const onUndo = () => { + const newObject = undo(); + if (newObject) { + updateCurrentObject(newObject); + } + }; + + const onRedo = () => { + const newObject = redo(); + if (newObject) { + updateCurrentObject(newObject); + } + }; + return (
@@ -335,6 +354,7 @@ const TransitRoutingForm: React.FC = (props) => { value={selectedRoutingModes} localePrefix="transit:transitPath:routingModes" onValueChange={(e) => onValueChange('routingModes', { value: e.target.value })} + key={`formFieldTransitRoutingRoutingModes${changeCount}`} /> )} @@ -347,18 +367,21 @@ const TransitRoutingForm: React.FC = (props) => { transitRouting.attributes.arrivalTimeSecondsSinceMidnight } onValueChange={onTripTimeChange} + key={`formFieldTransitRoutingTimeOfTrip${changeCount}`} /> )} {hasTransitModeSelected && ( )} {hasTransitModeSelected && ( = (props) => { = (props) => { originGeojson={transitRouting.attributes.originGeojson} destinationGeojson={transitRouting.attributes.destinationGeojson} onUpdateOD={onUpdateOD} + key={`formFieldTransitRoutingCoordinates${changeCount}`} /> onValueChange('routingName', value, false)} pattern={'[^,"\':;\r\n\t\\\\]*'} @@ -440,6 +466,7 @@ const TransitRoutingForm: React.FC = (props) => {
+ {loading && }