diff --git a/gtfs.yml b/gtfs.yml index 4170346d5..0674ed8f1 100644 --- a/gtfs.yml +++ b/gtfs.yml @@ -23,6 +23,11 @@ inputType: LANGUAGE columnWidth: 12 helpContent: "The feed_lang field contains a IETF BCP 47 language code specifying the default language used for the text in this feed. This setting helps GTFS consumers choose capitalization rules and other language-specific settings for the feed. For an introduction to IETF BCP 47, please refer to http://www.rfc-editor.org/rfc/bcp/bcp47.txt and http://www.w3.org/International/articles/language-tags/." + - name: "default_lang" + required: false + inputType: LANGUAGE + columnWidth: 12 + helpContent: "Defines the language used when the data consumer doesn’t know the language of the rider. It's often defined as en, English." - name: "feed_start_date" required: false inputType: DATE @@ -67,6 +72,16 @@ inputType: TEXT columnWidth: 12 helpContent: "The feed publisher can specify a string here that indicates the current version of their GTFS feed. GTFS-consuming applications can display this value to help feed publishers determine whether the latest version of their feed has been incorporated." + - name: "feed_contact_email" + required: false + inputType: EMAIL + columnWidth: 12 + helpContent: "Email address for communication regarding the GTFS dataset and data publishing practices." + - name: "feed_contact_url" + required: false + inputType: URL + columnWidth: 12 + helpContent: "URL for contact information, a web-form, support desk, or other tools for communication regarding the GTFS dataset and data publishing practices." - id: agency name: agency.txt @@ -139,7 +154,7 @@ columnWidth: 6 helpContent: "The stop_code field contains short text or a number that uniquely identifies the stop for passengers. Stop codes are often used in phone-based transit information systems or printed on stop signage to make it easier for riders to get a stop schedule or real-time arrival information for a particular stop." - name: "stop_name" - required: true + required: false inputType: TEXT bulkEditEnabled: true columnWidth: 12 @@ -151,12 +166,12 @@ columnWidth: 12 helpContent: "The stop_desc field contains a description of a stop. Please provide useful, quality information. Do not simply duplicate the name of the stop." - name: "stop_lat" - required: true + required: false inputType: LATITUDE columnWidth: 6 helpContent: "The stop_lat field contains the latitude of a stop or station. The field value must be a valid WGS 84 latitude." - name: "stop_lon" - required: true + required: false inputType: LONGITUDE columnWidth: 6 helpContent: "The stop_lon field contains the longitude of a stop or station. The field value must be a valid WGS 84 longitude value from -180 to 180." @@ -180,8 +195,19 @@ text: Stop (0) - value: '1' text: Station (1) - columnWidth: 12 + - value: '2' + text: Entrance/Exit (2) + - value: '3' + text: Generic Node (3) + - value: '4' + text: Boarding Area (4) + columnWidth: 7 helpContent: "The location_type field identifies whether this stop ID represents a stop or station. If no location type is specified, or the location_type is blank, stop IDs are treated as stops. Stations may have different properties from stops when they are represented on a map or used in trip planning." + - name: "platform_code" + required: false + inputType: TEXT + columnWidth: 5 + helpContent: "Platform identifier for a platform stop (a stop belonging to a station). This should be just the platform identifier (eg. G or 3)." - name: "parent_station" required: false inputType: GTFS_STOP @@ -297,6 +323,36 @@ inputType: POSITIVE_INT columnWidth: 6 helpContent: The route_sort_order field can be used to order the routes in a way which is ideal for presentation to customers. It must be a non-negative integer. Routes with smaller route_sort_order values should be displayed before routes with larger route_sort_order values. + - name: continuous_pickup + required: false + inputType: DROPDOWN + bulkEditEnabled: true + options: + - value: 0 + text: Continuous stopping pickup (0) + - value: 1 + text: No continuous stopping pickup (1) + - value: 2 + text: Must phone an agency to arrange continuous stopping pickup (2) + - value: 3 + text: Must coordinate with a driver to arrange continuous stopping pickup (3) + columnWidth: 12 + helpContent: Indicates whether a rider can board the transit vehicle anywhere along the vehicle’s travel path. + - name: continuous_drop_off + required: false + inputType: DROPDOWN + bulkEditEnabled: true + options: + - value: 0 + text: Continuous stopping drop-off (0) + - value: 1 + text: No continuous stopping drop-off (1) + - value: 2 + text: Must phone an agency to arrange continuous stopping drop-off (2) + - value: 3 + text: Must coordinate with a driver to arrange continuous stopping drop-off (3) + columnWidth: 12 + helpContent: Indicates whether a rider can alight from the transit vehicle at any point along the vehicle’s travel path. - name: route_url required: false inputType: URL diff --git a/lib/editor/actions/active.js b/lib/editor/actions/active.js index 49b74d68a..f7bf8fc37 100644 --- a/lib/editor/actions/active.js +++ b/lib/editor/actions/active.js @@ -6,10 +6,8 @@ import {createAction, type ActionType} from 'redux-actions' import {createVoidPayloadAction, secureFetch} from '../../common/actions' import {ENTITY} from '../constants' -import {newGtfsEntity, fetchBaseGtfs} from './editor' import {fetchFeedSourceAndProject} from '../../manager/actions/feeds' import {fetchGTFSEntities} from '../../manager/actions/versions' -import {saveTripPattern} from './tripPattern' import { getEditorNamespace, getTableById, @@ -18,10 +16,12 @@ import { subSubComponentList } from '../util/gtfs' import {getMapFromGtfsStrategy, entityIsNew} from '../util/objects' - import type {Entity, Feed} from '../../types' import type {dispatchFn, getStateFn, AppState} from '../../types/reducers' +import {saveTripPattern} from './tripPattern' +import {newGtfsEntity, fetchBaseGtfs} from './editor' + export const clearGtfsContent = createVoidPayloadAction('CLEAR_GTFSEDITOR_CONTENT') const receivedNewEntity = createAction( 'RECEIVE_NEW_ENTITY', @@ -331,14 +331,25 @@ export function saveEntity ( return } dispatch(savingActiveGtfsEntity()) - const notNew = !entityIsNew(entity) + // Add default vals for component + const defaults = {} + if (component === 'route') { + defaults.continuous_pickup = 1 // Default value for no continuous pickup + defaults.continuous_drop_off = 1 // Default value for no continuous drop off + } else if (component === 'feedinfo') { + defaults.default_lang = '' + defaults.feed_contact_url = '' + defaults.feed_contact_email = '' + } + const entityWithDefaults = {...defaults, ...(entity: any)} // add defaults, if any. + const notNew = !entityIsNew(entityWithDefaults) const method = notNew ? 'put' : 'post' - const idParam = notNew ? `/${entity.id || ''}` : '' + const idParam = notNew ? `/${entityWithDefaults.id || ''}` : '' const {sessionId} = getState().editor.data.lock const route = component === 'fare' ? 'fareattribute' : component const url = `/api/editor/secure/${route}${idParam}?feedId=${feedId}&sessionId=${sessionId || ''}` const mappingStrategy = getMapFromGtfsStrategy(component) - const data = mappingStrategy(entity) + const data = mappingStrategy(entityWithDefaults) return dispatch(secureFetch(url, method, data)) .then(res => res.json()) .then(savedEntity => { diff --git a/lib/editor/actions/editor.js b/lib/editor/actions/editor.js index d06d9edc9..2b8ec900b 100644 --- a/lib/editor/actions/editor.js +++ b/lib/editor/actions/editor.js @@ -383,6 +383,9 @@ export function fetchBaseGtfs ({ feed_version default_route_color default_route_type + default_lang + feed_contact_url + feed_contact_email } agency (limit: -1) { id diff --git a/lib/editor/actions/trip.js b/lib/editor/actions/trip.js index 8b25515c6..ddef25d49 100644 --- a/lib/editor/actions/trip.js +++ b/lib/editor/actions/trip.js @@ -1,5 +1,5 @@ // @flow - +import clone from 'lodash/cloneDeep' import {createAction, type ActionType} from 'redux-actions' import {snakeCaseKeys} from '../../common/util/map-keys' @@ -7,7 +7,6 @@ import {createVoidPayloadAction, fetchGraphQL, secureFetch} from '../../common/a import {setErrorMessage} from '../../manager/actions/status' import {entityIsNew} from '../util/objects' import {getEditorNamespace} from '../util/gtfs' - import type {Pattern, TimetableColumn, Trip} from '../../types' import type {dispatchFn, getStateFn, TripCounts} from '../../types/reducers' @@ -159,11 +158,21 @@ export function saveTripsForCalendar ( trips = trips.map(snakeCaseKeys) return Promise.all(trips.filter(t => t).map((trip, index) => { const tripExists = !entityIsNew(trip) && trip.id !== null + const tripCopy: any = clone((trip: any)) + // Add default value to continuous pickup if not provided + // Editing continuous pickup/drop off is not currently supported in the schedule editor + const defaults = { + continuous_pickup: 1, + continuous_drop_off: 1 + } + tripCopy.stop_times = tripCopy.stop_times.map((stopTime, index) => { + return {...defaults, ...(stopTime: any)} + }) const method = tripExists ? 'put' : 'post' const url = tripExists && trip.id ? `/api/editor/secure/trip/${trip.id}?feedId=${feedId}&sessionId=${sessionId}` : `/api/editor/secure/trip?feedId=${feedId}&sessionId=${sessionId}` - return dispatch(secureFetch(url, method, trip)) + return dispatch(secureFetch(url, method, tripCopy)) .then(res => res.json()) .catch(err => { console.warn(err) diff --git a/lib/editor/components/pattern/PatternStopCard.js b/lib/editor/components/pattern/PatternStopCard.js index 4ae24cdf5..e5aa5d053 100644 --- a/lib/editor/components/pattern/PatternStopCard.js +++ b/lib/editor/components/pattern/PatternStopCard.js @@ -1,20 +1,29 @@ // @flow import Icon from '@conveyal/woonerf/components/icon' -import React, {Component} from 'react' +import clone from 'lodash/cloneDeep' +import React, { Component } from 'react' import { DragSource, DropTarget } from 'react-dnd' -import { Row, Col, Collapse, FormGroup, ControlLabel, Checkbox } from 'react-bootstrap' +import { + Checkbox, + Col, + Collapse, + ControlLabel, + FormControl, + FormGroup, + Row +} from 'react-bootstrap' import * as activeActions from '../../actions/active' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' import {getEntityName, getAbbreviatedStopName} from '../../util/gtfs' import MinuteSecondInput from '../MinuteSecondInput' +import type {Feed, Pattern, PatternStop} from '../../../types' + import NormalizeStopTimesTip from './NormalizeStopTimesTip' import PatternStopButtons from './PatternStopButtons' -import type {Feed, Pattern, PatternStop} from '../../../types' - type Props = { active: boolean, activePattern: Pattern, @@ -42,6 +51,79 @@ type Props = { updatePatternStops: typeof tripPatternActions.updatePatternStops } +type PickupDropoffSelectProps = { + activePattern: Pattern, + controlLabel: string, + onChange: (evt: SyntheticInputEvent) => void, + selectType: string, + shouldHaveDisabledOption: boolean, + title: string, + value: string | number +} + +type State = { + initialDwellTime: number, + initialTravelTime: number, + update: boolean +} + +const pickupDropoffOptions = [ + { + value: 0, + text: 'Available (0)' + }, + { + value: 1, + text: 'Not available (1)' + }, + { + value: 2, + text: 'Must phone agency to arrange (2)' + }, + { + value: 3, + text: 'Must coordinate with driver to arrange (3)' + } +] + +/** renders the form control drop downs for dropOff/Pick up and also continuous */ +const PickupDropoffSelect = (props: PickupDropoffSelectProps) => { + const { + activePattern, + controlLabel, + onChange, + selectType, + shouldHaveDisabledOption, + title, + value + } = props + const hasShapeId = activePattern.shapeId === null + return ( + + + {controlLabel} + + + {pickupDropoffOptions.map(o => ( + + ))} + + + ) +} + const cardSource = { beginDrag (props: Props) { return { @@ -174,12 +256,6 @@ class PatternStopCard extends Component { } } -type State = { - initialDwellTime: number, - initialTravelTime: number, - update: boolean -} - class PatternStopContents extends Component { componentWillMount () { this.setState({ @@ -246,12 +322,25 @@ class PatternStopContents extends Component { updatePatternStops(activePattern, patternStops) } + _onPickupOrDropOffChange = (evt: SyntheticInputEvent) => { + const selectedOptionValue: number = parseInt(evt.target.value, 10) + const {activePattern, index, updatePatternStops} = this.props + const patternStops = [...activePattern.patternStops] + + const newPatternStop = clone(patternStops[index]) + newPatternStop[evt.target.id] = selectedOptionValue + patternStops[index] = newPatternStop + this.setState({update: true}) + updatePatternStops(activePattern, patternStops) + } + render () { - const {active, patternEdited, patternStop} = this.props + const {active, activePattern, patternEdited, patternStop} = this.props // This component has a special shouldComponentUpdate to ensure that state // is not overwritten with new props, so use state.update to check edited // state. const isEdited = patternEdited || this.state.update + let innerDiv if (active) { innerDiv =
@@ -301,6 +390,55 @@ class PatternStopContents extends Component { + {/* Pickup and drop off type selectors */} + + + + + + + + + + + + + + + +
} diff --git a/lib/editor/util/map.js b/lib/editor/util/map.js index 6dd7e3636..3abb91bfe 100644 --- a/lib/editor/util/map.js +++ b/lib/editor/util/map.js @@ -754,6 +754,8 @@ export function stopToPatternStop ( id: generateUID(), stopSequence, stopId: stop.stop_id, + continuousDropOff: 1, + continuousPickup: 1, defaultDwellTime: 0, defaultTravelTime: 0, dropOffType: 0, diff --git a/lib/editor/util/validation.js b/lib/editor/util/validation.js index f6451d01c..d6ddd7601 100644 --- a/lib/editor/util/validation.js +++ b/lib/editor/util/validation.js @@ -36,8 +36,13 @@ export function validate ( const valueDoesNotExist = doesNotExist(value) const isRequiredButEmpty = required && valueDoesNotExist const isOptionalAndEmpty = !required && valueDoesNotExist - let reason = 'Required field must not be empty' const agencies = getTableById(tableData, 'agency') + let locationType: ?number = null + + // entity.locationtype is a string. Convert to number for conditinals later on. + if (entity && entity.location_type !== null) { + locationType = parseInt(entity.location_type, 10) + } // setting as a variable here because of eslint bug type CheckPositiveOutput = { @@ -45,13 +50,29 @@ export function validate ( result: false | EditorValidationIssue } + /** + * Construct an EditorValidationIssue for the field name and reason (defaults to + * empty field message). + */ + function validationIssue (reason: string, field = name) { + return {field, invalid: true, reason} + } + + /** + * Construct an EditorValidationIssue for this field, used if it is required + * and has an empty value. + */ + function emptyFieldValidationIssue (field = name) { + return validationIssue('Required field must not be empty', field) + } + /** * Checks whether value is a positive number */ function checkPositiveNumber (): CheckPositiveOutput { if (isRequiredButEmpty) { return { - result: {field: name, invalid: isRequiredButEmpty, reason} + result: emptyFieldValidationIssue() } } else if (isOptionalAndEmpty) { return { @@ -63,14 +84,14 @@ export function validate ( // make sure value is parseable to a number if (isNaN(num)) { return { - result: {field: name, invalid: true, reason: 'Field must be a valid number'} + result: validationIssue('Field must be a valid number') } } // make sure value is positive if (num < 0) { return { - result: {field: name, invalid: true, reason: 'Field must be a positive number'} + result: validationIssue('Field must be a positive number') } } @@ -100,15 +121,29 @@ export function validate ( (indices.length > 1 || (indices.length > 0 && entities[indices[0]].id !== entity.id)) ) - if (isRequiredButEmpty || isNotUnique) { - if (isNotUnique) { - reason = 'Identifier must be unique' - } - return {field: name, invalid: isRequiredButEmpty || isNotUnique, reason} + if ( + name === 'agency_id' && + idList.length > 1 && + valueDoesNotExist + ) { + return validationIssue('Identifier is required if more than one agency exists') + } + if (isRequiredButEmpty) { + return emptyFieldValidationIssue() + } else if (isNotUnique) { + return validationIssue('Identifier must be unique') } else { return false } case 'TEXT': + if ( + name === 'stop_name' && + !value && + locationType !== null && + (typeof locationType === 'number' && locationType <= 2) + ) { + return validationIssue('Stop name is required for stop, station, and entrance location types.') + } if (name === 'route_short_name' && !value && entity && entity.route_long_name) { return false } else if ( @@ -120,7 +155,7 @@ export function validate ( return false } else { if (isRequiredButEmpty) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } else { return false } @@ -131,80 +166,71 @@ export function validate ( case 'GTFS_FARE': case 'GTFS_SERVICE': if (isRequiredButEmpty) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } else { return false } case 'URL': const isNotUrl = value && !validator.isURL(value) - if (isRequiredButEmpty || isNotUrl) { - if (isNotUrl) { - reason = 'Field must contain valid URL.' - } - return {field: name, invalid: isRequiredButEmpty || isNotUrl, reason} + if (isRequiredButEmpty) { + return emptyFieldValidationIssue() + } else if (isNotUrl) { + return validationIssue('Field must contain valid URL.') } else { return false } case 'EMAIL': const isNotEmail = value && !validator.isEmail(value) - if (isRequiredButEmpty || isNotEmail) { - if (isNotEmail) { - reason = 'Field must contain valid email address.' - } - return {field: name, invalid: isRequiredButEmpty || isNotEmail, reason} + if (isRequiredButEmpty) { + return emptyFieldValidationIssue() + } else if (isNotEmail) { + return validationIssue('Field must contain valid email address.') } else { return false } case 'GTFS_ZONE': if (isRequiredButEmpty) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } else { return false } case 'TIMEZONE': if (isRequiredButEmpty) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } else { return false } case 'LANGUAGE': if (isRequiredButEmpty) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } else { return false } case 'LATITUDE': const isNotLat = value > 90 || value < -90 - if (isRequiredButEmpty || isNotLat) { - if (isNotLat) { - reason = 'Field must be valid latitude.' - } - return {field: name, invalid: isRequiredButEmpty || isNotLat, reason} - } else { - return false + if (isNotLat) { + return validationIssue('Field must be valid latitude.') + } + if (isOptionalAndEmpty && locationType !== null && (typeof locationType === 'number' && locationType <= 2)) { + return validationIssue('Latitude and Longitude are required for your current location type') } + return false case 'LONGITUDE': const isNotLng = value > 180 || value < -180 - if (isRequiredButEmpty || isNotLng) { - if (isNotLng) { - reason = 'Field must be valid longitude.' - } - return {field: name, invalid: isRequiredButEmpty || isNotLng, reason} - } else { - return false + if (isNotLng) { + return validationIssue('Field must be valid longitude.') + } + if (isOptionalAndEmpty && typeof locationType === 'number' && locationType <= 2) { + return validationIssue('Latitude and Longitude are required for your current location type') } + return false case 'TIME': case 'NUMBER': const isNotANumber = isNaN(value) - if (isRequiredButEmpty || isNotANumber) { - if (isNotANumber) { - reason = 'Field must be valid number' - } - return { - field: name, - invalid: isRequiredButEmpty || isNotANumber, - reason - } + if (isRequiredButEmpty) { + return emptyFieldValidationIssue() + } else if (isNotANumber) { + return validationIssue('Field must be valid number') } else { return false } @@ -226,8 +252,7 @@ export function validate ( } if (!hasService && name === 'monday') { // only add validation issue for one day of week (monday) - reason = 'Calendar must have service for at least one day' - return {field: name, invalid: isRequiredButEmpty, reason} + return validationIssue('Calendar must have service for at least one day') } return false case 'DROPDOWN': @@ -236,7 +261,7 @@ export function validate ( field.options && field.options.findIndex(o => o.value === '') === -1 ) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } else { return false } @@ -246,11 +271,7 @@ export function validate ( agencies.length > 1 ) { if (valueDoesNotExist) { - return { - field: name, - invalid: true, - reason: 'Field must be populated for feeds with more than one agency.' - } + return validationIssue('Field must be populated for feeds with more than one agency.') } } return false @@ -279,18 +300,19 @@ export function validate ( } } if (!value || value.length === 0) { - return {field: `dates`, invalid: true, reason} + return emptyFieldValidationIssue('dates') } // check if date already exists in this or other exceptions for (let i = 0; i < value.length; i++) { + const dateItemName = `dates-${i}` if (dateMap[value[i]] && dateMap[value[i]].length > 1) { // eslint-disable-next-line standard/computed-property-even-spacing - reason = `Date (${value[ + const reason = `Date (${value[ i ]}) cannot appear more than once for all exceptions` - return {field: `dates-${i}`, invalid: true, reason} + return validationIssue(reason, dateItemName) } else if (!moment(value[i], 'YYYYMMDD', true).isValid()) { - return {field: `dates-${i}`, invalid: true, reason} + return emptyFieldValidationIssue(dateItemName) } } return false @@ -310,7 +332,7 @@ export function validate ( ) ) ) { - return {field: name, invalid: true, reason: 'Field must be a positive integer'} + return validationIssue('Field must be a positive integer') } return false case 'POSITIVE_NUM': @@ -321,7 +343,7 @@ export function validate ( case 'COLOR': default: if (isRequiredButEmpty) { - return {field: name, invalid: isRequiredButEmpty, reason} + return emptyFieldValidationIssue() } return false } diff --git a/lib/gtfs/util/index.js b/lib/gtfs/util/index.js index 20fd5d4ab..a65575e91 100644 --- a/lib/gtfs/util/index.js +++ b/lib/gtfs/util/index.js @@ -79,6 +79,8 @@ export function getGraphQLFieldsForEntity (type: string, editor: boolean = false pickup_type drop_off_type timepoint + continuous_pickup + continuous_drop_off }` switch (type.toLowerCase()) { case 'stoptime': diff --git a/lib/types/index.js b/lib/types/index.js index b3a66ccb1..096bc7770 100644 --- a/lib/types/index.js +++ b/lib/types/index.js @@ -565,6 +565,8 @@ export type GeoJsonFeatureCollection = { } export type PatternStop = { + continuousDropOff: number, + continuousPickup: number, defaultDwellTime: number, defaultTravelTime: number, dropOffType: number,