Skip to content

Commit e858742

Browse files
committed
transitRouting: use the useHistoryTracking for routing form
Let the form use the `useHistoryTracking` hook to allow to track object changes history. As the `TransitRouting` object is a simple one, without ID, or DB representation, it is a good candidate to be the first to migrate to the new form structure, using hooks instead of inheritance for history tracking. Also add an UndoRedoButtons widget to allow to undo/redo changes in the form.
1 parent 6be5cd3 commit e858742

File tree

2 files changed

+165
-77
lines changed

2 files changed

+165
-77
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2025, Polytechnique Montreal and contributors
3+
*
4+
* This file is licensed under the MIT License.
5+
* License text available at https://opensource.org/licenses/MIT
6+
*/
7+
import React from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { faUndoAlt } from '@fortawesome/free-solid-svg-icons/faUndoAlt';
10+
import { faRedoAlt } from '@fortawesome/free-solid-svg-icons/faRedoAlt';
11+
12+
import Button from '../input/Button';
13+
14+
export type UndoRedoButtonsProps = {
15+
/**
16+
* Function called after the undo button was clicked and the last action was
17+
* undone
18+
*/
19+
onUndo: () => void;
20+
/**
21+
* Function called after the redo button was clicked and the last action was
22+
* redone
23+
*/
24+
onRedo: () => void;
25+
canRedo: () => boolean;
26+
canUndo: () => boolean;
27+
};
28+
29+
const SelectedObjectButtons: React.FunctionComponent<UndoRedoButtonsProps> = (props: UndoRedoButtonsProps) => {
30+
const { t } = useTranslation(['main', 'notifications']);
31+
32+
return (
33+
<div className="tr__form-buttons-container tr__form-selected-object-buttons-container">
34+
{
35+
<Button
36+
title={t('main:Undo')}
37+
name="undo"
38+
key="undo"
39+
color="grey"
40+
disabled={!props.canUndo()}
41+
icon={faUndoAlt}
42+
iconClass="_icon-alone"
43+
label=""
44+
onClick={props.onUndo}
45+
/>
46+
}
47+
{
48+
<Button
49+
title={t('main:Redo')}
50+
name="redo"
51+
key="redo"
52+
color="grey"
53+
disabled={!props.canRedo()}
54+
icon={faRedoAlt}
55+
iconClass="_icon-alone"
56+
label=""
57+
onClick={props.onRedo}
58+
/>
59+
}
60+
</div>
61+
);
62+
};
63+
64+
export default SelectedObjectButtons;

packages/transition-frontend/src/components/forms/transitRouting/TransitRoutingForm.tsx

Lines changed: 101 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This file is licensed under the MIT License.
55
* License text available at https://opensource.org/licenses/MIT
66
*/
7-
import React, { useState, useRef, useEffect } from 'react';
7+
import React, { useState, useEffect, useCallback, useRef } from 'react';
88
import Collapsible from 'react-collapsible';
99
import { useTranslation } from 'react-i18next';
1010
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons/faCheckCircle';
@@ -36,67 +36,83 @@ import { EventManager } from 'chaire-lib-common/lib/services/events/EventManager
3636
import { MapUpdateLayerEventType } from 'chaire-lib-frontend/lib/services/map/events/MapEventsCallbacks';
3737
import { calculateRouting } from '../../../services/routing/RoutingUtils';
3838
import { RoutingResultsByMode } from 'chaire-lib-common/lib/services/routing/types';
39+
import { useHistoryTracker } from 'chaire-lib-frontend/lib/components/forms/useHistoryTracker';
40+
import UndoRedoButtons from 'chaire-lib-frontend/lib/components/pageParts/UndoRedoButtons';
3941

4042
export interface TransitRoutingFormProps {
4143
availableRoutingModes?: string[];
4244
}
4345

44-
const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
46+
const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props: TransitRoutingFormProps) => {
4547
// State hooks to replace class state
46-
const transitRouting = useRef<TransitRouting>(
48+
const transitRoutingRef = useRef<TransitRouting>(
4749
new TransitRouting(_cloneDeep(Preferences.get('transit.routing.transit')))
48-
).current;
50+
);
51+
const transitRouting = transitRoutingRef.current;
4952
// State value is not used
5053
const [, setRoutingAttributes] = useState<TransitRoutingAttributes>(transitRouting.attributes);
51-
// FIXME using any to avoid typing the formValues, which would be tedious, will be rewritten soon anyway
52-
const [formValues, setFormValues] = useState<any>(() => ({
53-
routingName: transitRouting.attributes.routingName || '',
54-
routingModes: transitRouting.attributes.routingModes || ['transit'],
55-
minWaitingTimeSeconds: transitRouting.attributes.minWaitingTimeSeconds,
56-
maxAccessEgressTravelTimeSeconds: transitRouting.attributes.maxAccessEgressTravelTimeSeconds,
57-
maxTransferTravelTimeSeconds: transitRouting.attributes.maxTransferTravelTimeSeconds,
58-
maxFirstWaitingTimeSeconds: transitRouting.attributes.maxFirstWaitingTimeSeconds,
59-
maxTotalTravelTimeSeconds: transitRouting.attributes.maxTotalTravelTimeSeconds,
60-
scenarioId: transitRouting.attributes.scenarioId,
61-
withAlternatives: transitRouting.attributes.withAlternatives
62-
}));
63-
6454
const [currentResult, setCurrentResult] = useState<RoutingResultsByMode | undefined>(undefined);
6555
const [scenarioCollection, setScenarioCollection] = useState(serviceLocator.collectionManager.get('scenarios'));
6656
const [loading, setLoading] = useState(false);
6757
const [routingErrors, setRoutingErrors] = useState<ErrorMessage[] | undefined>(undefined);
6858
const [selectedMode, setSelectedMode] = useState<RoutingOrTransitMode | undefined>(undefined);
59+
const [changeCount, setChangeCount] = useState(0); // Used to force a rerender when the object changes
6960

7061
const { t } = useTranslation(['transit', 'main', 'form']);
7162

7263
// Using refs for stateful values that don't trigger renders
73-
const invalidFieldsRef = useRef<{ [key: string]: boolean }>({});
7464
const calculateRoutingNonceRef = useRef<object>(new Object());
7565

76-
// Functionality from ChangeEventsForm
77-
const hasInvalidFields = (): boolean => {
78-
return Object.keys(invalidFieldsRef.current).filter((key) => invalidFieldsRef.current[key]).length > 0;
79-
};
66+
const {
67+
onValueChange: onFieldValueChange,
68+
hasInvalidFields,
69+
formValues,
70+
updateHistory,
71+
canRedo,
72+
canUndo,
73+
undo,
74+
redo
75+
} = useHistoryTracker({ object: transitRouting });
76+
77+
// Update scenario collection when it changes
78+
const onScenarioCollectionUpdate = useCallback(() => {
79+
setScenarioCollection(serviceLocator.collectionManager.get('scenarios'));
80+
}, []);
8081

81-
const onFormFieldChange = (
82-
path: string,
83-
newValue: { value: any; valid?: boolean } = { value: null, valid: true }
84-
) => {
85-
setFormValues((prevValues) => ({ ...prevValues, [path]: newValue.value }));
86-
if (newValue.valid !== undefined && !newValue.valid) {
87-
invalidFieldsRef.current[path] = true;
88-
} else {
89-
invalidFieldsRef.current[path] = false;
82+
// Setup event listeners on mount and cleanup on unmount
83+
useEffect(() => {
84+
serviceLocator.eventManager.on('collection.update.scenarios', onScenarioCollectionUpdate);
85+
86+
return () => {
87+
serviceLocator.eventManager.off('collection.update.scenarios', onScenarioCollectionUpdate);
88+
};
89+
}, [onScenarioCollectionUpdate]);
90+
91+
// Setup event listeners on mount and cleanup on unmount
92+
useEffect(() => {
93+
if (transitRouting.hasOrigin()) {
94+
(serviceLocator.eventManager as EventManager).emitEvent<MapUpdateLayerEventType>('map.updateLayer', {
95+
layerName: 'routingPoints',
96+
data: transitRouting.originDestinationToGeojson()
97+
});
9098
}
91-
};
99+
}, [changeCount]);
100+
101+
const resetResultsData = useCallback(() => {
102+
setCurrentResult(undefined);
103+
serviceLocator.eventManager.emit('map.updateLayers', {
104+
routingPaths: undefined,
105+
routingPathsStrokes: undefined
106+
});
107+
}, []);
92108

93-
const onValueChange = (
109+
const onValueChange = useCallback((
94110
path: keyof TransitRoutingAttributes,
95111
newValue: { value: any; valid?: boolean } = { value: null, valid: true },
96112
resetResults = true
97113
) => {
98114
setRoutingErrors([]); //When a value is changed, remove the current routingErrors to stop displaying them.
99-
onFormFieldChange(path, newValue);
115+
onFieldValueChange(path, newValue);
100116
if (newValue.valid || newValue.valid === undefined) {
101117
const updatedObject = transitRouting;
102118
updatedObject.set(path, newValue.value);
@@ -106,15 +122,8 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
106122
if (resetResults) {
107123
resetResultsData();
108124
}
109-
};
110-
111-
const resetResultsData = () => {
112-
setCurrentResult(undefined);
113-
serviceLocator.eventManager.emit('map.updateLayers', {
114-
routingPaths: undefined,
115-
routingPathsStrokes: undefined
116-
});
117-
};
125+
updateHistory();
126+
}, [onFieldValueChange, resetResultsData, transitRouting, updateHistory]);
118127

119128
const isValid = (): boolean => {
120129
// Are all form fields valid and the routing object too
@@ -129,11 +138,11 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
129138
originGeojson,
130139
destinationGeojson
131140
} = routing.attributes;
141+
132142
if (!originGeojson || !destinationGeojson) {
133143
return;
134144
}
135-
// Save the origin et destinations lat/lon, and time, along with whether it is arrival or departure
136-
// TODO Support specifying departure/arrival as variable in batch routing
145+
137146
routing.addElementForBatch({
138147
routingName,
139148
departureTimeSecondsSinceMidnight,
@@ -144,10 +153,6 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
144153

145154
routing.set('routingName', ''); // empty routing name for the next route
146155
setRoutingAttributes({ ...routing.attributes });
147-
setFormValues((prevValues) => ({
148-
...prevValues,
149-
routingName: routing.attributes.routingName
150-
}));
151156
};
152157

153158
const calculate = async (refresh = false) => {
@@ -213,20 +218,19 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
213218
}
214219
setRoutingAttributes({ ...routing.attributes });
215220
setCurrentResult(undefined);
216-
};
217-
218-
const onScenarioCollectionUpdate = () => {
219-
setScenarioCollection(serviceLocator.collectionManager.get('scenarios'));
221+
updateHistory();
220222
};
221223

222224
const downloadCsv = () => {
223225
const elements = transitRouting.attributes.savedForBatch;
224226
const lines: string[] = [];
225227
lines.push('id,routingName,originLon,originLat,destinationLon,destinationLat,time');
228+
226229
elements.forEach((element, index) => {
227230
const time = !_isBlank(element.arrivalTimeSecondsSinceMidnight)
228231
? element.arrivalTimeSecondsSinceMidnight
229232
: element.departureTimeSecondsSinceMidnight;
233+
230234
lines.push(
231235
index +
232236
',' +
@@ -243,6 +247,7 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
243247
(!_isBlank(time) ? secondsSinceMidnightToTimeStr(time as number) : '')
244248
);
245249
});
250+
246251
const csvFileContent = lines.join('\n');
247252

248253
const element = document.createElement('a');
@@ -257,32 +262,18 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
257262
const updatedObject = transitRouting;
258263
updatedObject.resetBatchSelection();
259264
setRoutingAttributes({ ...updatedObject.attributes });
265+
updateHistory();
260266
};
261267

262-
const onTripTimeChange = (time: { value: any; valid?: boolean }, timeType: 'departure' | 'arrival') => {
263-
onValueChange(
264-
timeType === 'departure' ? 'departureTimeSecondsSinceMidnight' : 'arrivalTimeSecondsSinceMidnight',
265-
time
266-
);
267-
};
268-
269-
// Handle componentDidMount and componentWillUnmount
270-
useEffect(() => {
271-
// ComponentDidMount
272-
if (transitRouting.hasOrigin()) {
273-
(serviceLocator.eventManager as EventManager).emitEvent<MapUpdateLayerEventType>('map.updateLayer', {
274-
layerName: 'routingPoints',
275-
data: transitRouting.originDestinationToGeojson()
276-
});
277-
}
278-
279-
serviceLocator.eventManager.on('collection.update.scenarios', onScenarioCollectionUpdate);
280-
281-
// ComponentWillUnmount
282-
return () => {
283-
serviceLocator.eventManager.off('collection.update.scenarios', onScenarioCollectionUpdate);
284-
};
285-
}, []);
268+
const onTripTimeChange = useCallback(
269+
(time: { value: any; valid?: boolean }, timeType: 'departure' | 'arrival') => {
270+
onValueChange(
271+
timeType === 'departure' ? 'departureTimeSecondsSinceMidnight' : 'arrivalTimeSecondsSinceMidnight',
272+
time
273+
);
274+
},
275+
[onValueChange]
276+
);
286277

287278
// 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.
288279
// In that case, change it to undefined.
@@ -319,6 +310,31 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
319310
};
320311
});
321312

313+
const updateCurrentObject = (newObject: TransitRouting) => {
314+
transitRoutingRef.current = newObject;
315+
resetResultsData();
316+
setChangeCount(changeCount + 1);
317+
// Update routing preferences if the object is valid.
318+
// FIXME Should we calculate too?
319+
if (isValid()) {
320+
newObject.updateRoutingPrefs();
321+
}
322+
};
323+
324+
const onUndo = () => {
325+
const newObject = undo();
326+
if (newObject) {
327+
updateCurrentObject(newObject);
328+
}
329+
};
330+
331+
const onRedo = () => {
332+
const newObject = redo();
333+
if (newObject) {
334+
updateCurrentObject(newObject);
335+
}
336+
};
337+
322338
return (
323339
<React.Fragment>
324340
<form id="tr__form-transit-routing" className="tr__form-transit-routing apptr__form">
@@ -335,6 +351,7 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
335351
value={selectedRoutingModes}
336352
localePrefix="transit:transitPath:routingModes"
337353
onValueChange={(e) => onValueChange('routingModes', { value: e.target.value })}
354+
key={`formFieldTransitRoutingRoutingModes${changeCount}`}
338355
/>
339356
</InputWrapper>
340357
)}
@@ -347,18 +364,21 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
347364
transitRouting.attributes.arrivalTimeSecondsSinceMidnight
348365
}
349366
onValueChange={onTripTimeChange}
367+
key={`formFieldTransitRoutingTimeOfTrip${changeCount}`}
350368
/>
351369
)}
352370
{hasTransitModeSelected && (
353371
<TransitRoutingBaseComponent
354372
onValueChange={onValueChange}
355373
attributes={transitRouting.attributes}
374+
key={`formFieldTransitRoutingBaseComponents${changeCount}`}
356375
/>
357376
)}
358377
{hasTransitModeSelected && (
359378
<InputWrapper label={t('transit:transitRouting:Scenario')}>
360379
<InputSelect
361380
id={'formFieldTransitRoutingScenario'}
381+
key={`formFieldTransitRoutingScenario${changeCount}`}
362382
value={formValues.scenarioId}
363383
choices={scenarios}
364384
t={t}
@@ -370,6 +390,7 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
370390
<InputWrapper label={t('transit:transitRouting:WithAlternatives')}>
371391
<InputRadio
372392
id={'formFieldTransitRoutingWithAlternatives'}
393+
key={`formFieldTransitRoutingWithAlternatives${changeCount}`}
373394
value={formValues.withAlternatives}
374395
sameLine={true}
375396
disabled={false}
@@ -396,10 +417,12 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
396417
originGeojson={transitRouting.attributes.originGeojson}
397418
destinationGeojson={transitRouting.attributes.destinationGeojson}
398419
onUpdateOD={onUpdateOD}
420+
key={`formFieldTransitRoutingCoordinates${changeCount}`}
399421
/>
400422
<InputWrapper label={t('transit:transitRouting:RoutingName')}>
401423
<InputString
402424
id={'formFieldTransitRoutingRoutingName'}
425+
key={`formFieldTransitRoutingRoutingName${changeCount}`}
403426
value={formValues.routingName}
404427
onValueUpdated={(value) => onValueChange('routingName', value, false)}
405428
pattern={'[^,"\':;\r\n\t\\\\]*'}
@@ -440,6 +463,7 @@ const TransitRoutingForm: React.FC<TransitRoutingFormProps> = (props) => {
440463

441464
<div>
442465
<div className="tr__form-buttons-container">
466+
<UndoRedoButtons canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} />
443467
{loading && <Loader size={8} color={'#aaaaaa'} loading={true}></Loader>}
444468
<span title={t('main:Calculate')}>
445469
<Button

0 commit comments

Comments
 (0)