diff --git a/__tests__/e2e/server/launch.sh b/__tests__/e2e/server/launch.sh index 63fdc2870..6be43afa7 100644 --- a/__tests__/e2e/server/launch.sh +++ b/__tests__/e2e/server/launch.sh @@ -4,7 +4,7 @@ java -jar otp-1.4.0-shaded.jar --server --autoScan --basePath /tmp/otp --insecure --router default & # Start the second process -java -jar datatools-server-3.8.1-SNAPSHOT.jar /config/env.yml /config/server.yml & +java -XX:-UseContainerSupport -jar datatools-server-3.8.1-SNAPSHOT.jar /config/env.yml /config/server.yml & # Wait for any process to exit wait -n diff --git a/__tests__/end-to-end.js b/__tests__/end-to-end.js index 7c53faeb0..2f2f9a68d 100644 --- a/__tests__/end-to-end.js +++ b/__tests__/end-to-end.js @@ -803,6 +803,51 @@ async function elementType (elementHandle: any, selector: string, text: string) await selectedElement.type(text) } +/** + * A helper function to create a new fare media by filling out the fare media + * form and saving it. This function assumes that the fare media sidebar form is + * open and ready to be filled out. + */ +async function createNewFareMedia () { + await waitForAndClick('[data-test-id="create-first-faremedia-button"]') + + // fare_media_id + await type( + '[data-test-id="faremedia-fare_media_id-input-container"] input', + '1' + ) + + // name + await type( + '[data-test-id="faremedia-fare_media_name-input-container"] input', + 'Test Fare Media' + ) + + // type + await page.select( + '[data-test-id="faremedia-fare_media_type-input-container"] select', + '1' // physical paper ticket + ) + + // save + await click('[data-test-id="save-entity-button"]') + await wait(2000, 'for save to happen') + + // reload to make sure stuff was saved + await page.reload({ waitUntil: 'networkidle0' }) + + // wait for fare media sidebar form to appear + await waitForSelector( + '[data-test-id="faremedia-fare_media_id-input-container"]' + ) + + // verify data was saved and retrieved from server + await expectSelectorToContainHtml( + '[data-test-id="faremedia-fare_media_id-input-container"]', + '1' + ) +} + // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Start of test suite @@ -2424,6 +2469,122 @@ describe('end-to-end', () => { }, defaultTestTimeout, 'should create fare') }) + // fares v2 tests + describe('fares v2', () => { + makeEditorEntityTest('should create fare media', async () => { + // open fares v2 sidebar + await click('[data-test-id="editor-fareproduct-nav-button"]') + + // click dropdown to select fare_media + await reactSelectOption( + '[data-test-id="virtualized-entity-select-fareproduct"]', + 'faremedia', + 1, + true + ) + + // wait for fare media sidebar form to appear and click create button + await createNewFareMedia() + }, defaultTestTimeout) + makeEditorEntityTest('should update fare media data', async () => { + // update fare media name by appending to end + await appendText( + '[data-test-id="faremedia-fare_media_name-input-container"] input', + ' updated' + ) + + // save + await click('[data-test-id="save-entity-button"]') + await wait(2000, 'for save to happen') + + // reload to make sure stuff was saved + await page.reload({ waitUntil: 'networkidle0' }) + + // wait for fare media sidebar form to appear + await waitForSelector( + '[data-test-id="faremedia-fare_media_name-input-container"] input' + ) + + // verify data was saved and retrieved from server + await expectSelectorToContainHtml( + '[data-test-id="faremedia-fare_media_name-input-container"]', + 'Test Fare Media updated' + ) + }, defaultTestTimeout, 'should create fare media') + makeEditorEntityTest('should delete fare media', async () => { + // delete the fare media + await click('[data-test-id="delete-faremedia-button"]') + await waitForAndClick('[data-test-id="modal-confirm-ok-button"]') + await wait(2000, 'for delete to happen') + + // verify that fare media to delete is no longer listed + await expectSelectorToContainHtml( + '[data-test-id="create-first-faremedia-button"]', + 'Create first media' + ) + }, defaultTestTimeout, 'should create fare media') + makeEditorEntityTest('should create fare product with fare media', async () => { + await createNewFareMedia() + + // click dropdown to select fare_product + await reactSelectOption( + '[data-test-id="virtualized-entity-select-faremedia"]', + 'fareproduct', + 1, + true + ) + + // wait for fare product sidebar form to appear and click create button + await waitForAndClick('[data-test-id="create-first-fareproduct-button"]') + + // fill out fare product form + await type( + '[data-test-id="fareproduct-fare_product_id-input-container"] input', + 'test-fare-product-id' + ) + await type( + '[data-test-id="fareproduct-fare_product_name-input-container"] input', + 'Test Fare Product' + ) + // select the previously created fare media + await reactSelectOption( + '[data-test-id="fareproduct-fare_media_id-input-container"]', + 'Test Fare Media (1)', + 1 + ) + + // amount + await type( + '[data-test-id="fareproduct-amount-input-container"] input', + '2.50' + ) + + // currency + await page.select( + '[data-test-id="fareproduct-currency-input-container"] select', + 'USD' + ) + + // save + await click('[data-test-id="save-entity-button"]') + await wait(2000, 'for save to happen') + + // reload to make sure stuff was saved + await page.reload({ waitUntil: 'networkidle0' }) + + // wait for fare product sidebar form to appear + await waitForSelector( + '[data-test-id="fareproduct-fare_product_id-input-container"]' + ) + + // verify data was saved and retrieved from server + await expectSelectorToContainHtml( + '[data-test-id="fareproduct-fare_product_id-input-container"]', + 'test-fare-product-id' + ) + }, defaultTestTimeout, 'should create fare media') + }) + // --------------------------------------------------------------------------- // Pattern tests // --------------------------------------------------------------------------- diff --git a/gtfs.yml b/gtfs.yml index 746df907d..952fe5172 100644 --- a/gtfs.yml +++ b/gtfs.yml @@ -233,7 +233,12 @@ text: Not possible (2) columnWidth: 12 helpContent: "The wheelchair_boarding field identifies whether wheelchair boardings are possible from the specified stop or station. The field can have the following values:" - + - name: "stop_area_ids" + required: false + inputType: GTFS_STOP_AREA + bulkEditEnabled: true + columnWidth: 12 + helpContent: "The area_id field defines the area that this stop belongs to. This is used to group stops together in a logical way, such as all stops in a particular neighborhood or district. If this stop is not part of an area, leave this field blank." - id: route name: routes.txt @@ -549,6 +554,18 @@ inputType: URL columnWidth: 12 helpContent: + - name: network_id + required: false + inputType: GTFS_NETWORK + bulkEditEnabled: true + columnWidth: 12 + helpContent: "The network_id field defines the network that this route belongs to. This is used to group routes together in a logical way, such as all routes in a particular region or operated by a particular brand. If this route is not part of a network, leave this field blank." + - name: route_network_ids + required: false + inputType: GTFS_ROUTE_NETWORK + bulkEditEnabled: true + columnWidth: 12 + helpContent: "The network_id field defines the network that this route belongs to. This is used to group routes together in a logical way, such as all routes in a particular region or operated by a particular brand. If this route is not part of a network, leave this field blank." - id: trip name: trips.txt @@ -1017,3 +1034,256 @@ required: false - name: added_service required: false + +# Fares v2 + +- id: fareproduct + name: fare_products.txt + helpContent: Used to describe the range of fares available for purchase by riders or taken into account when computing the total fare for journeys with multiple legs, such as transfer costs. + fields: + - name: fare_product_id + required: true + inputType: GTFS_ID + columnWidth: 12 + - name: fare_product_name + required: false + inputType: TEXT + columnWidth: 12 + - name: fare_media_id + required: false + inputType: GTFS_FARE_MEDIA + columnWidth: 12 + - name: rider_category_id + required: false + inputType: GTFS_RIDER_CATEGORY + columnWidth: 12 + - name: amount + inputType: NUMBER + required: true + columnWidth: 12 + - name: currency + required: true + inputType: DROPDOWN + bulkEditEnabled: true + options: + - value: USD + text: US dollar (USD) + - value: AUD + text: Australian dollar (AUD) + - value: CAD + text: Canadian dollar (CAD) + - value: CHF + text: Swiss franc (CHF) + - value: CNH + text: Chinese renminbi (CNH) + - value: EUR + text: Euro (EUR) + - value: GBP + text: Pound sterling (GBP) + - value: JPY + text: Japanese yen (JPY) + - value: MXN + text: Mexican peso (MXN) + - value: NZD + text: New Zealand dollar (NZD) + - value: SEK + text: Swedish krona (SEK) +- id: faremedia + name: fare_media.txt + helpContent: To describe the different fare media that can be employed to use fare products. Fare media are physical or virtual holders used for the representation and/or validation of a fare product. + fields: + - name: fare_media_id + required: true + inputType: GTFS_ID + columnWidth: 12 + - name: fare_media_name + inputType: TEXT + columnWidth: 12 + - name: fare_media_type + required: true + inputType: DROPDOWN + options: + - value: '0' + text: None (0) + - value: '1' + text: Physical paper ticket (1) + - value: '2' + text: Physical transit card (2) + - value: '3' + text: cEMV (3) + - value: '4' + text: Mobile app (4) +- id: faretransferrule + name: fare_transfer_rules.txt + helpContent: Fare rules for transfers between legs of travel defined in fare_leg_rules.txt. + fields: + - name: from_leg_group_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference fare_leg_rules? + helpContent: "Identifies a group of pre-transfer fare leg rules." + columnWidth: 12 + - name: to_leg_group_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference fare_leg_rules? + helpContent: "Identifies a group of post-transfer fare leg rules." + columnWidth: 12 + - name: transfer_count + inputType: NUMBER # FARE-TODO: NON-ZERO INT + helpContent: "Defines how many consecutive transfers the transfer rule may be applied to." + columnWidth: 12 + - name: duration_limit + inputType: POSITIVE_INT + helpContent: "Duration limit in seconds for a transfer. If there is no duration limit then leave empty." + columnWidth: 12 + - name: duration_limit_type + helpContent: "Defines the relative start and end of duration_limit." + inputType: DROPDOWN + options: + - value: '0' + text: 'Dep -> Arr (0)' + - value: '1' + text: 'Dep -> Dep (1)' + - value: '2' + text: 'Arr -> Dep (2)' + - value: '3' + text: 'Arr -> Arr (3)' + - name: fare_transfer_type + required: true + inputType: DROPDOWN + helpContent: "Cost processing method of transferring between legs in a journey" + options: + - value: '0' + text: 'From + Transfer (A + AB) (0)' + - value: '1' + text: 'From + Transfer + To (A + AB + B) (1)' + - value: '2' + text: 'Transfer only (AB) (2)' + - name: fare_product_id + required: false + inputType: GTFS_FARE_PRODUCT + helpContent: "The fare product required to transfer between two fare legs. If empty, the cost of the transfer rule is 0." + columnWidth: 12 +- id: farelegrule + name: fare_leg_rules.txt + helpContent: Fare rules for individual legs of travel. + fields: + - name: leg_group_id + required: false + inputType: GTFS_ID + columnWidth: 12 + - name: network_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference networks.network_id or routes.network_id + columnWidth: 12 + - name: from_area_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference areas.area_id + columnWidth: 12 + - name: to_area_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference areas.area_id + columnWidth: 12 + - name: from_timeframe_group_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference timeframes.timeframe_group_id + columnWidth: 12 + - name: to_timeframe_group_id + required: false + inputType: TEXT # FARE-TODO: Needs to reference timeframes.timeframe_group_id + columnWidth: 12 + - name: fare_product_id + required: true + inputType: GTFS_FARE_PRODUCT + columnWidth: 12 + - name: rule_priority + required: false + inputType: POSITIVE_INT + columnWidth: 12 +- id: area + name: areas.txt + helpContent: Areas that can be used to define fare zones. + fields: + - name: area_id + required: true + inputType: GTFS_ID + columnWidth: 12 + - name: area_name + required: false + inputType: TEXT + columnWidth: 12 +- id: network + name: networks.txt + helpContent: Networks that can be used to group routes and fare legs. + fields: + - name: network_id + required: true + inputType: GTFS_ID + columnWidth: 12 + - name: network_name + required: false + inputType: TEXT + columnWidth: 12 +- id: timeframe + name: timeframes.txt + helpContent: Timeframes are the time windows that enclose the periods of different fares. e.g":" A timeframe enclosing weekday morning rush hour for peak fares, a timeframe enclosing weekend evening for discounted fares, etc. + fields: + - name: timeframe_group_id + required: true + inputType: GTFS_ID + columnWidth: 12 + - name: start_time # FARE-TODO: this and end_time are conditionally required, must have both or neither + required: false + inputType: TIME + columnWidth: 12 + - name: end_time + required: false + inputType: TIME + columnWidth: 12 + - name: service_id + required: true + inputType: GTFS_SERVICE + columnWidth: 12 +- id: ridercategory + name: rider_categories.txt + helpContent: Categories of riders that can be used to define fare products. + fields: + - name: rider_category_id + required: true + inputType: GTFS_ID + columnWidth: 12 + - name: rider_category_name + required: true + inputType: TEXT + columnWidth: 12 + - name: is_default_fare_category + required: true + inputType: DROPDOWN + options: + - value: '0' + text: No (0) + - value: '1' + text: Yes (1) + - name: eligibility_url + required: false + inputType: URL + columnWidth: 12 +- id: farelegjoinrule + name: fare_leg_join_rules.txt + helpContent: Rules for joining fare legs when calculating total fare for a journey with multiple legs. + fields: + - name: from_network_id + required: true + inputType: TEXT # FARE-TODO: Needs to reference routes.network_id or networks.network_id + columnWidth: 12 + - name: to_network_id + required: true + inputType: TEXT # FARE-TODO: Needs to reference routes.network_id or networks.network_id + columnWidth: 12 + - name: from_stop_id + required: false + inputType: GTFS_STOP + columnWidth: 12 + - name: to_stop_id + required: false + inputType: GTFS_STOP + columnWidth: 12 \ No newline at end of file diff --git a/i18n/english.yml b/i18n/english.yml index c76daf5e3..b34b9e679 100644 --- a/i18n/english.yml +++ b/i18n/english.yml @@ -690,6 +690,9 @@ components: fare: title: Edit fares label: Fares + faresv2: + title: Edit fares v2 + label: Fares v2 feedinfo: title: Edit feed info label: Feed Info @@ -1440,6 +1443,7 @@ components: customServiceId: Custom service ID dateServiceIdCombinationDuplicate: Date (%exceptionDate%) and Service ID (%serviceId%) combination cannot appear more than once for all exceptions. idMustBeUnique: Identifier must be unique. + fareProductIdCombinationDuplicate: Records with the same fare_product_id must differ by fare_media_id or rider_category_id. idRequired: Identifier is required if more than one agency exists. invalidEmail: Field must contain valid email address. invalidLatitude: Field must be valid latitude. diff --git a/lib/editor/actions/editor.js b/lib/editor/actions/editor.js index b83e5ad06..f5328e46e 100644 --- a/lib/editor/actions/editor.js +++ b/lib/editor/actions/editor.js @@ -489,6 +489,11 @@ export function fetchBaseGtfs ({ agency_id agency_name } + area (limit: -1) { + id + area_id + area_name + } calendar (limit: -1) { id service_id @@ -498,6 +503,41 @@ export function fetchBaseGtfs ({ id fare_id } + fare_product (limit: -1) { + id + fare_product_id + fare_media_id + } + fare_media (limit: -1) { + id + fare_media_id + fare_media_name + } + fare_transfer_rule (limit: -1) { + id + from_leg_group_id + to_leg_group_id + fare_product_id + } + fare_leg_rule (limit: -1) { + id + fare_product_id + leg_group_id + } + fare_leg_join_rule (limit: -1) { + id + from_network_id + to_network_id + } + network (limit: -1) { + id + network_id + network_name + } + rider_category (limit: -1) { + id + rider_category_id + } routes (limit: -1) { id route_id @@ -527,6 +567,13 @@ export function fetchBaseGtfs ({ stop_lon zone_id # needed for fares } + time_frame { + id + timeframe_group_id + start_time + end_time + service_id + } } } ` diff --git a/lib/editor/components/EditorInput.js b/lib/editor/components/EditorInput.js index b72248c54..a845eff0f 100644 --- a/lib/editor/components/EditorInput.js +++ b/lib/editor/components/EditorInput.js @@ -16,10 +16,11 @@ import TimezoneSelect from '../../common/components/TimezoneSelect' import LanguageSelect from '../../common/components/LanguageSelect' import {getComponentMessages} from '../../common/util/config' import toSentenceCase from '../../common/util/text' -import type {Entity, Feed, GtfsSpecField, GtfsAgency, GtfsStop} from '../../types' +import type {Entity, Feed, GtfsAgency, GtfsRoute, GtfsSpecField, GtfsStop} from '../../types' import type {EditorTables} from '../../types/reducers' import ColorField from './ColorField' +import GenericEntitySelector from './EntitiySelector' import RouteTypeSelect from './RouteTypeSelect' import VirtualizedEntitySelect from './VirtualizedEntitySelect' import ZoneSelect from './ZoneSelect' @@ -109,6 +110,7 @@ export default class EditorInput extends React.Component { } } + /* eslint-disable complexity */ render () { const { activeEntity, @@ -221,8 +223,9 @@ export default class EditorInput extends React.Component { + /> ) case 'TIMEZONE': @@ -230,9 +233,10 @@ export default class EditorInput extends React.Component { {basicLabel} + onChange={this._onSelectChange} + value={currentValue} + /> ) case 'LANGUAGE': @@ -386,6 +390,121 @@ export default class EditorInput extends React.Component { ) } + // TODO: fare_product and fare_media could likely be refactored to use the GenericEntitySelector component. + case 'GTFS_FARE_PRODUCT': + const fareProducts = getTableById(tableData, 'fareproduct').map(fareProduct => ({ + label: `${fareProduct.fare_product_id} (${fareProduct.fare_media_id})`, + value: fareProduct.fare_product_id + })) + return ( + + {basicLabel} + + + ) + case 'GTFS_STOP_AREA': + // FARE-TODO: add area selector for fare_leg_rule editing + const areas = getTableById(tableData, 'area') + let stopAreaIds: Array = [] + if (activeComponent === 'stop') { + const stop = ((activeEntity: any): GtfsStop) + stopAreaIds = stop.stop_area_ids ? stop.stop_area_ids.split('§').filter(Boolean) : [] + } + return ( + + {basicLabel} + + + ) + case 'GTFS_ROUTE_NETWORK': + // FARE-TODO: Show onhover if disabled explaining why. + const networks = getTableById(tableData, 'network') + let routeNetworkIds: Array = [] + let routeNetworkDisabled = false + if (activeComponent === 'route') { + const route = ((activeEntity: any): GtfsRoute) + if (route.network_id !== null) { + routeNetworkDisabled = true + } + routeNetworkIds = route.route_network_ids ? route.route_network_ids.split('§').filter(Boolean) : [] + } + return ( + + {basicLabel} + + + ) + case 'GTFS_NETWORK': + // FARE-TODO: if network_id is set on route, need to send empty string for route_network_ids on save. + // FARE-TODO: Show onhover if disabled explaining why. + const stringValue = typeof fieldProps.value === 'undefined' + ? '' + : fieldProps.value + let networkDisabled = false + if (activeComponent === 'route') { + const route = ((activeEntity: any): GtfsRoute) + if (route.route_network_ids !== null && route.route_network_ids !== '') { + networkDisabled = true + } + } + return ( + + {basicLabel} + + + ) + case 'GTFS_RIDER_CATEGORY': + const riderCategories = getTableById(tableData, 'ridercategory').map(riderCategory => ({ + label: `${riderCategory.rider_category_id}`, + value: riderCategory.rider_category_id + })) + return ( + + {basicLabel} +