Skip to content

Commit 8c85cb8

Browse files
Merge pull request #1280 from opentripplanner/disable-sinlge-itinerary-days
Configure the ability to save trip for only one day
2 parents ca8995a + 4c3c5d5 commit 8c85cb8

File tree

7 files changed

+188
-99
lines changed

7 files changed

+188
-99
lines changed

example-config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,8 @@ itinerary:
436436
advancedSettingsPanel:
437437
# Show button in advanced panel that allows users to save and return
438438
saveAndReturnButton: true
439+
# Prevent users from selecting a single day for saving trips.
440+
disableSingleItineraryDays: false
439441

440442
# The transitOperators key is a list of transit operators that can be used to
441443
# order transit agencies when sorting by route. Also, this can optionally

i18n/en-US.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,7 @@ components:
534534
SavedTripScreen:
535535
itineraryLoaded: Itinerary loaded
536536
itineraryLoading: Loading itinerary
537+
selectAtLeastOneDay: Please select at least one day to monitor.
537538
tooManyTrips: >
538539
You already have reached the maximum of five saved trips. Please remove
539540
unused trips from your saved trips, and try again.

i18n/fr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ components:
559559
SavedTripScreen:
560560
itineraryLoaded: Trajet chargé
561561
itineraryLoading: Chargement du trajet
562+
selectAtLeastOneDay: Veuillez choisir au moins un jour pour le suivi.
562563
tooManyTrips: >
563564
Vous avez déjà atteint le nombre maximum de 5 trajets enregistrés.
564565
Veuillez supprimer les trajets enregistrés qui sont inutilisés, puis

lib/components/user/monitored-trip/saved-trip-screen.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,14 @@ class SavedTripScreen extends Component {
149149
}
150150

151151
render() {
152-
const { isCreating, itinerary, loggedInUser, monitoredTrips, pending } =
153-
this.props
152+
const {
153+
disableSingleItineraryDays,
154+
isCreating,
155+
itinerary,
156+
loggedInUser,
157+
monitoredTrips,
158+
pending
159+
} = this.props
154160
const isAwaiting = !monitoredTrips || (isCreating && pending)
155161

156162
let screenContents
@@ -176,7 +182,32 @@ class SavedTripScreen extends Component {
176182
// Text constant is used to allow format.js command line tool to keep track of
177183
// which IDs are in the code.
178184
.notOneOf(otherTripNames, 'trip-name-already-used')
179-
const validationSchema = yup.object(clonedSchemaShape)
185+
const validationSchema = yup
186+
.object(clonedSchemaShape)
187+
// If disableSingleItineraryDays is true, test to see if at least one day checkbox is checked
188+
.test('dayofweek', 'Please select one day', function (obj) {
189+
if (
190+
obj.monday ||
191+
obj.tuesday ||
192+
obj.wednesday ||
193+
obj.thursday ||
194+
obj.friday ||
195+
obj.saturday ||
196+
obj.sunday ||
197+
!disableSingleItineraryDays
198+
) {
199+
return true
200+
}
201+
202+
/* Hack: because the selected days values are not grouped, we need to assign this error to one of the
203+
checkboxes so that form validates correctly. Monday makes sure the focus is on the first checkbox. */
204+
205+
return new yup.ValidationError(
206+
'Please select at least one day to monitor',
207+
obj.monday,
208+
'monday'
209+
)
210+
})
180211

181212
screenContents = (
182213
<Formik
@@ -236,8 +267,10 @@ const mapStateToProps = (state, ownProps) => {
236267
const pending = activeSearch ? Boolean(activeSearch.pending) : false
237268
const itineraries = getActiveItineraries(state) || []
238269
const tripId = ownProps.match.params.id
270+
const { disableSingleItineraryDays } = state.otp.config
239271
return {
240272
activeSearchId: state.otp.activeSearchId,
273+
disableSingleItineraryDays,
241274
homeTimezone: state.otp.config.homeTimezone,
242275
isCreating: tripId === 'new',
243276
itinerary: itineraries[activeItinerary],

lib/components/user/monitored-trip/trip-basics-pane.tsx

Lines changed: 143 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
Radio
1010
} from 'react-bootstrap'
1111
import { Field, FormikProps } from 'formik'
12-
import { FormattedMessage, injectIntl } from 'react-intl'
12+
import { FormattedMessage, injectIntl, useIntl } from 'react-intl'
1313
import { Prompt } from 'react-router'
1414
// @ts-expect-error FormikErrorFocus does not support TypeScript yet.
1515
import FormikErrorFocus from 'formik-error-focus'
@@ -46,6 +46,7 @@ type TripBasicsProps = WrappedComponentProps &
4646
intl: IntlShape
4747
) => void
4848
clearItineraryExistence: () => void
49+
disableSingleItineraryDays?: boolean
4950
isCreating: boolean
5051
itineraryExistence?: ItineraryExistence
5152
}
@@ -132,6 +133,97 @@ function isDisabled(day: string, itineraryExistence?: ItineraryExistence) {
132133
return itineraryExistence && !itineraryExistence[day]?.valid
133134
}
134135

136+
const RenderAvailableDays = ({
137+
errorCheckingTrip,
138+
errorSelectingDays,
139+
finalItineraryExistence,
140+
isCreating,
141+
monitoredTrip
142+
}: {
143+
errorCheckingTrip: boolean
144+
errorSelectingDays?: 'error' | null
145+
finalItineraryExistence?: ItineraryExistence
146+
isCreating: boolean
147+
monitoredTrip: MonitoredTrip
148+
}) => {
149+
const intl = useIntl()
150+
const baseColor = getBaseColor()
151+
return (
152+
<>
153+
{errorCheckingTrip && (
154+
<>
155+
{/* FIXME: Temporary solution until itinerary existence check is fixed. */}
156+
<br />
157+
<FormattedMessage id="actions.user.itineraryExistenceCheckFailed" />
158+
</>
159+
)}
160+
<AvailableDays>
161+
{ALL_DAYS.map((day) => {
162+
const isDayDisabled = isDisabled(day, finalItineraryExistence)
163+
const labelClass = isDayDisabled ? 'disabled-day' : ''
164+
const notAvailableText = isDayDisabled
165+
? intl.formatMessage(
166+
{
167+
id: 'components.TripBasicsPane.tripNotAvailableOnDay'
168+
},
169+
{
170+
repeatedDay: getFormattedDayOfWeekPlural(day, intl)
171+
}
172+
)
173+
: ''
174+
175+
return (
176+
<MonitoredDayCircle
177+
baseColor={baseColor}
178+
key={day}
179+
monitored={!isDayDisabled && monitoredTrip[day]}
180+
title={notAvailableText}
181+
>
182+
<Field
183+
// Let users save an existing trip, even though it may not be available on some days.
184+
// TODO: improve checking trip availability.
185+
disabled={isDayDisabled && isCreating}
186+
id={day}
187+
name={day}
188+
type="checkbox"
189+
/>
190+
<Ban aria-hidden />
191+
<label htmlFor={day}>
192+
<InvisibleA11yLabel>
193+
<FormattedDayOfWeek day={day} />
194+
</InvisibleA11yLabel>
195+
<span aria-hidden className={labelClass}>
196+
{/* The abbreviated text is visual only. Screen readers should read out the full day. */}
197+
<FormattedDayOfWeekCompact day={day} />
198+
</span>
199+
</label>
200+
<InvisibleA11yLabel>{notAvailableText}</InvisibleA11yLabel>
201+
</MonitoredDayCircle>
202+
)
203+
})}
204+
</AvailableDays>
205+
<HelpBlock role="status">
206+
{finalItineraryExistence ? (
207+
<FormattedMessage id="components.TripBasicsPane.tripIsAvailableOnDaysIndicated" />
208+
) : (
209+
<ProgressBar
210+
active
211+
label={
212+
<FormattedMessage id="components.TripBasicsPane.checkingItineraryExistence" />
213+
}
214+
now={100}
215+
/>
216+
)}
217+
</HelpBlock>
218+
<HelpBlock role="alert">
219+
{errorSelectingDays && (
220+
<FormattedValidationError type="select-at-least-one-day" />
221+
)}
222+
</HelpBlock>
223+
</>
224+
)
225+
}
226+
135227
/**
136228
* This component shows summary information for a trip
137229
* and lets the user edit the trip name and day.
@@ -220,6 +312,7 @@ class TripBasicsPane extends Component<TripBasicsProps, State> {
220312
const {
221313
canceled,
222314
dirty,
315+
disableSingleItineraryDays,
223316
errors,
224317
intl,
225318
isCreating,
@@ -257,6 +350,9 @@ class TripBasicsPane extends Component<TripBasicsProps, State> {
257350
const errorCheckingTrip = ALL_DAYS.every((day) =>
258351
isDisabled(day, finalItineraryExistence)
259352
)
353+
/* Hack: because the selected days checkboxes are not grouped, we need to assign this error to one of the
354+
checkboxes so that the FormikErrorFocus works. */
355+
const selectOneDayError = errorStates.monday
260356
return (
261357
<div>
262358
{/* TODO: This component does not block navigation on reload or using the back button.
@@ -286,104 +382,53 @@ class TripBasicsPane extends Component<TripBasicsProps, State> {
286382
)}
287383
</HelpBlock>
288384
</FormGroup>
289-
290-
<FormGroup>
291-
<ControlLabel>
292-
<FormattedMessage id="components.TripBasicsPane.tripDaysPrompt" />
293-
</ControlLabel>
294-
<Radio
295-
checked={!isOneTime}
296-
// FIXME: Temporary solution until itinerary existence check is fixed.
297-
disabled={errorCheckingTrip}
298-
onChange={this._handleRecurringTrip}
299-
>
300-
<FormattedMessage id="components.TripBasicsPane.recurringEachWeek" />
301-
{errorCheckingTrip && (
385+
{disableSingleItineraryDays ? (
386+
<FormGroup validationState={selectOneDayError}>
387+
<ControlLabel>
388+
<FormattedMessage id="components.TripBasicsPane.tripDaysPrompt" />
389+
</ControlLabel>
390+
<RenderAvailableDays
391+
errorCheckingTrip={errorCheckingTrip}
392+
errorSelectingDays={selectOneDayError}
393+
finalItineraryExistence={finalItineraryExistence}
394+
isCreating={isCreating}
395+
monitoredTrip={monitoredTrip}
396+
/>
397+
</FormGroup>
398+
) : (
399+
<FormGroup>
400+
<ControlLabel>
401+
<FormattedMessage id="components.TripBasicsPane.tripDaysPrompt" />
402+
</ControlLabel>
403+
<Radio
404+
checked={!isOneTime}
405+
// FIXME: Temporary solution until itinerary existence check is fixed.
406+
disabled={errorCheckingTrip}
407+
onChange={this._handleRecurringTrip}
408+
>
409+
<FormattedMessage id="components.TripBasicsPane.recurringEachWeek" />
410+
</Radio>
411+
{!isOneTime && (
302412
<>
303-
{/* FIXME: Temporary solution until itinerary existence check is fixed. */}
304-
<br />
305-
<FormattedMessage id="actions.user.itineraryExistenceCheckFailed" />
413+
<RenderAvailableDays
414+
errorCheckingTrip={errorCheckingTrip}
415+
finalItineraryExistence={finalItineraryExistence}
416+
isCreating={isCreating}
417+
monitoredTrip={monitoredTrip}
418+
/>
306419
</>
307420
)}
308-
</Radio>
309-
{!isOneTime && (
310-
<>
311-
<AvailableDays>
312-
{ALL_DAYS.map((day) => {
313-
const isDayDisabled = isDisabled(
314-
day,
315-
finalItineraryExistence
316-
)
317-
const labelClass = isDayDisabled ? 'disabled-day' : ''
318-
const notAvailableText = isDayDisabled
319-
? intl.formatMessage(
320-
{
321-
id: 'components.TripBasicsPane.tripNotAvailableOnDay'
322-
},
323-
{
324-
repeatedDay: getFormattedDayOfWeekPlural(day, intl)
325-
}
326-
)
327-
: ''
328-
329-
const baseColor = getBaseColor()
330-
return (
331-
<MonitoredDayCircle
332-
baseColor={baseColor}
333-
key={day}
334-
monitored={!isDayDisabled && monitoredTrip[day]}
335-
title={notAvailableText}
336-
>
337-
<Field
338-
// Let users save an existing trip, even though it may not be available on some days.
339-
// TODO: improve checking trip availability.
340-
disabled={isDayDisabled && isCreating}
341-
id={day}
342-
name={day}
343-
type="checkbox"
344-
/>
345-
<Ban aria-hidden />
346-
<label htmlFor={day}>
347-
<InvisibleA11yLabel>
348-
<FormattedDayOfWeek day={day} />
349-
</InvisibleA11yLabel>
350-
<span aria-hidden className={labelClass}>
351-
{/* The abbreviated text is visual only. Screen readers should read out the full day. */}
352-
<FormattedDayOfWeekCompact day={day} />
353-
</span>
354-
</label>
355-
<InvisibleA11yLabel>
356-
{notAvailableText}
357-
</InvisibleA11yLabel>
358-
</MonitoredDayCircle>
359-
)
360-
})}
361-
</AvailableDays>
362-
<HelpBlock role="status">
363-
{finalItineraryExistence ? (
364-
<FormattedMessage id="components.TripBasicsPane.tripIsAvailableOnDaysIndicated" />
365-
) : (
366-
<ProgressBar
367-
active
368-
label={
369-
<FormattedMessage id="components.TripBasicsPane.checkingItineraryExistence" />
370-
}
371-
now={100}
372-
/>
373-
)}
374-
</HelpBlock>
375-
</>
376-
)}
377-
<Radio checked={isOneTime} onChange={this._handleOneTimeTrip}>
378-
<FormattedMessage
379-
id="components.TripBasicsPane.onlyOnDate"
380-
values={{ date: itinerary.startTime }}
381-
/>
382-
</Radio>
383-
384-
{/* Scroll to the trip name/days fields if submitting and there is an error on these fields. */}
385-
<FormikErrorFocus align="middle" duration={200} />
386-
</FormGroup>
421+
<Radio checked={isOneTime} onChange={this._handleOneTimeTrip}>
422+
<FormattedMessage
423+
id="components.TripBasicsPane.onlyOnDate"
424+
values={{ date: itinerary.startTime }}
425+
/>
426+
</Radio>
427+
</FormGroup>
428+
)}
429+
430+
{/* Scroll to the trip name/days fields if submitting and there is an error on these fields. */}
431+
<FormikErrorFocus align="middle" duration={200} />
387432
</div>
388433
)
389434
}
@@ -394,7 +439,9 @@ class TripBasicsPane extends Component<TripBasicsProps, State> {
394439

395440
const mapStateToProps = (state: AppReduxState) => {
396441
const { itineraryExistence } = state.user
442+
const { disableSingleItineraryDays } = state.otp.config
397443
return {
444+
disableSingleItineraryDays,
398445
itineraryExistence
399446
}
400447
}

lib/components/util/formatted-validation-error.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export default function FormattedValidationError({ type }) {
2828
return (
2929
<FormattedMessage id="components.SavedTripScreen.tripNameRequired" />
3030
)
31+
case 'select-at-least-one-day':
32+
return (
33+
<FormattedMessage id="components.SavedTripScreen.selectAtLeastOneDay" />
34+
)
3135
default:
3236
return null
3337
}

lib/util/config-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ export interface AppConfig {
382382
co2?: CO2Config
383383
companies?: Company[]
384384
dateTime?: DateTimeConfig
385+
disableSingleItineraryDays?: boolean
385386
elevationProfile?: boolean
386387
extraMenuItems?: AppMenuItemConfig[]
387388
geocoder: GeocoderConfig

0 commit comments

Comments
 (0)