Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d08b892
add prefix 'region/' to all continent labels for easier readability i…
ianktc Dec 18, 2025
20a8ca6
add auth required label when it is present in the feed form
ianktc Dec 18, 2025
9a2fb11
add URL check to see if it produces valid zip file
ianktc Dec 18, 2025
09ae736
add checks in feed form for pre-existing feeds based on direct downlo…
ianktc Dec 19, 2025
1a4e19f
make the mbd URL prefix a constant
ianktc Dec 19, 2025
ca54fdd
fix linter errors
ianktc Dec 22, 2025
72ec281
fix linter errors
ianktc Dec 22, 2025
c40ed8d
fix linter errors
ianktc Dec 22, 2025
c1bba5e
fix linter errors
ianktc Dec 22, 2025
74fd4eb
fix linter errors
ianktc Dec 22, 2025
90e9db2
fix linter errors
ianktc Dec 22, 2025
940f349
fix linter errors
ianktc Dec 22, 2025
334c2af
fix linter errors
ianktc Dec 22, 2025
c0b7de0
fix linter errors
ianktc Dec 22, 2025
1f2f118
readd the 'at least 1 url' validation
ianktc Dec 22, 2025
e7e6362
fix the 'at least 1 url' validation
ianktc Dec 22, 2025
ec4156e
fix linter error by specifying type for papaparse
ianktc Dec 22, 2025
6c56462
fix linter errors by removing csv row type checking
ianktc Dec 22, 2025
382e9af
add papaparse dependency
ianktc Dec 22, 2025
499b7a3
remove papaparse dependency (in wrong place)
ianktc Dec 22, 2025
1be0a22
update yarn.lock
ianktc Dec 22, 2025
59c7d0d
Merge branch 'main' into feat/check-preexisting-feed-client-side
ianktc Dec 22, 2025
c07d259
move feed-form issue creation to separate PR
ianktc Dec 22, 2025
bf56602
Merge branch 'feat/check-preexisting-feed-client-side' of github.com:…
ianktc Dec 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"recharts": "^2.12.7",
"redux-persist": "^6.0.0",
"redux-saga": "^1.2.3",
"yup": "^1.3.2"
"yup": "^1.3.2",
"papaparse": "^5.5.3"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
Expand Down
1 change: 1 addition & 0 deletions web-app/public/locales/en/feeds.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"errorSubmitting": "An error occurred while submitting the form.",
"submittingFeed": "Submitting the feed...",
"errorUrl": "The URL must start with a valid protocol: http:// or https://",
"feedAlreadyExists": "This feed URL already exists in the Mobility Database: ",
"unofficialDesc": "Why was this feed created?",
"unofficialDescPlaceholder": "Does this feed exist for research purposes, a specific app, etc?",
"updateFreq": "How often is this feed updated?",
Expand Down
45 changes: 41 additions & 4 deletions web-app/src/app/screens/FeedSubmission/Form/FirstStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
import { type YesNoFormInput, type FeedSubmissionFormFormInput } from '.';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { isValidFeedLink } from '../../../services/feeds/utils';
import {
isValidFeedLink,
checkFeedUrlExistsInCsv,
} from '../../../services/feeds/utils';
import FormLabelDescription from './components/FormLabelDescription';

export interface FeedSubmissionFormFormInputFirstStep {
Expand All @@ -42,6 +45,8 @@ interface FormFirstStepProps {
setNumberOfSteps: (numberOfSteps: YesNoFormInput) => void;
}

const scheduleFeedURLPrefix = 'https://mobilitydatabase.org/feeds/gtfs/';

export default function FormFirstStep({
initialValues,
submitFormData,
Expand Down Expand Up @@ -267,18 +272,50 @@ export default function FormFirstStep({
<Controller
rules={{
required: t('form.feedLinkRequired'),
validate: (value) =>
isValidFeedLink(value ?? '') || t('form.errorUrl'),
validate: async (value) => {
if (!isValidFeedLink(value ?? '')) {
return t('form.errorUrl');
}
const exists = await checkFeedUrlExistsInCsv(value ?? '');
if (typeof exists === 'string' && exists.length > 0) {
return `Feed Exists:${exists}`;
}
return true;
},
}}
control={control}
name='feedLink'
render={({ field }) => (
<TextField
data-cy='feedLink'
className='md-small-input'
helperText={errors.feedLink?.message ?? ''}
error={errors.feedLink !== undefined}
{...field}
helperText={
typeof errors.feedLink?.message === 'string' &&
errors.feedLink?.message?.startsWith('Feed Exists:') ? (
<span>
{t('form.feedAlreadyExists')}
<a
href={errors.feedLink.message.replace(
'Feed Exists:',
`${scheduleFeedURLPrefix}`,
)}
target='_blank'
rel='noopener noreferrer'
>
{t(
errors.feedLink.message.replace(
'Feed Exists:',
'',
),
)}
</a>
</span>
) : (
errors.feedLink?.message ?? ''
)
}
/>
)}
/>
Expand Down
133 changes: 126 additions & 7 deletions web-app/src/app/screens/FeedSubmission/Form/SecondStepRealtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { type SubmitHandler, Controller, useForm } from 'react-hook-form';
import { type AuthTypes, type FeedSubmissionFormFormInput } from '.';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { isValidFeedLink } from '../../../services/feeds/utils';
import {
isValidFeedLink,
checkFeedUrlExistsInCsv,
} from '../../../services/feeds/utils';

export interface FeedSubmissionFormInputSecondStepRT {
tripUpdates: string;
Expand All @@ -32,6 +35,8 @@ interface FormSecondStepRTProps {
handleBack: (formData: Partial<FeedSubmissionFormFormInput>) => void;
}

const realtimeFeedURLPrefix = 'https://mobilitydatabase.org/feeds/gtfs_rt/';

export default function FormSecondStepRT({
initialValues,
submitFormData,
Expand Down Expand Up @@ -125,14 +130,52 @@ export default function FormSecondStepRT({
<Controller
control={control}
name='serviceAlerts'
rules={{ validate: () => gtfsRtLinkValidation('sa') }}
rules={{
validate: async (value) => {
const atLeastOneFeed = gtfsRtLinkValidation('sa');
if (atLeastOneFeed !== true) {
return atLeastOneFeed;
}
const exists = await checkFeedUrlExistsInCsv(value ?? '');
if (typeof exists === 'string' && exists.length > 0) {
return `Feed Exists:${exists}`;
}
return true;
},
}}
render={({ field }) => (
<TextField
className='md-small-input'
{...field}
helperText={errors.serviceAlerts?.message ?? ''}
error={errors.serviceAlerts !== undefined}
data-cy='serviceAlertFeed'
helperText={
typeof errors.serviceAlerts?.message === 'string' &&
errors.serviceAlerts?.message?.startsWith(
'Feed Exists:',
) ? (
<span>
{t('form.feedAlreadyExists')}
<a
href={errors.serviceAlerts.message.replace(
'Feed Exists:',
`${realtimeFeedURLPrefix}`,
)}
target='_blank'
rel='noopener noreferrer'
>
{t(
errors.serviceAlerts.message.replace(
'Feed Exists:',
'',
),
)}
</a>
</span>
) : (
errors.serviceAlerts?.message ?? ''
)
}
/>
)}
/>
Expand Down Expand Up @@ -181,13 +224,51 @@ export default function FormSecondStepRT({
<Controller
control={control}
name='tripUpdates'
rules={{ validate: () => gtfsRtLinkValidation('tu') }}
rules={{
validate: async (value) => {
const atLeastOneFeed = gtfsRtLinkValidation('tu');
if (atLeastOneFeed !== true) {
return atLeastOneFeed;
}
const exists = await checkFeedUrlExistsInCsv(value ?? '');
if (typeof exists === 'string' && exists.length > 0) {
return `Feed Exists:${exists}`;
}
return true;
},
}}
render={({ field }) => (
<TextField
className='md-small-input'
{...field}
helperText={errors.tripUpdates?.message ?? ''}
error={errors.tripUpdates !== undefined}
helperText={
typeof errors.tripUpdates?.message === 'string' &&
errors.tripUpdates?.message?.startsWith(
'Feed Exists:',
) ? (
<span>
{t('form.feedAlreadyExists')}
<a
href={errors.tripUpdates.message.replace(
'Feed Exists:',
`${realtimeFeedURLPrefix}`,
)}
target='_blank'
rel='noopener noreferrer'
>
{t(
errors.tripUpdates.message.replace(
'Feed Exists:',
'',
),
)}
</a>
</span>
) : (
errors.tripUpdates?.message ?? ''
)
}
/>
)}
/>
Expand Down Expand Up @@ -236,13 +317,51 @@ export default function FormSecondStepRT({
<Controller
control={control}
name='vehiclePositions'
rules={{ validate: () => gtfsRtLinkValidation('vp') }}
rules={{
validate: async (value) => {
const atLeastOneFeed = gtfsRtLinkValidation('vp');
if (atLeastOneFeed !== true) {
return atLeastOneFeed;
}
const exists = await checkFeedUrlExistsInCsv(value ?? '');
if (typeof exists === 'string' && exists.length > 0) {
return `Feed Exists:${exists}`;
}
return true;
},
}}
render={({ field }) => (
<TextField
className='md-small-input'
{...field}
helperText={errors.vehiclePositions?.message ?? ''}
error={errors.vehiclePositions !== undefined}
helperText={
typeof errors.vehiclePositions?.message === 'string' &&
errors.vehiclePositions?.message?.startsWith(
'Feed Exists:',
) ? (
<span>
{t('form.feedAlreadyExists')}
<a
href={errors.vehiclePositions.message.replace(
'Feed Exists:',
`${realtimeFeedURLPrefix}`,
)}
target='_blank'
rel='noopener noreferrer'
>
{t(
errors.vehiclePositions.message.replace(
'Feed Exists:',
'',
),
)}
</a>
</span>
) : (
errors.vehiclePositions?.message ?? ''
)
}
/>
)}
/>
Expand Down
33 changes: 33 additions & 0 deletions web-app/src/app/services/feeds/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Papa from 'papaparse';
import { getEmojiFlag, type TCountryCode, languages } from 'countries-list';
import { type paths, type components } from './types';

Expand Down Expand Up @@ -150,3 +151,35 @@ export const langCodeToName = (code: string): string => {
const lang = languages[primary as keyof typeof languages];
return lang?.name ?? code.toUpperCase();
};

/**
* Checks if a feed URL exists in the urls.direct_download column of the feeds_v2.csv file.
* @param feedUrl The URL to check for existence.
* @param csvUrl The CSV file URL (default: feeds_v2.csv from Mobility Database)
* @returns Promise<boolean> true if exists, false otherwise
*/
export async function checkFeedUrlExistsInCsv(
feedUrl: string,
csvUrl = 'https://files.mobilitydatabase.org/feeds_v2.csv',
): Promise<string | null> {
try {
const response = await fetch(csvUrl);
if (!response.ok) throw new Error('Failed to fetch CSV');
const csvText = await response.text();
const parsed = Papa.parse<FeedCsvRow>(csvText, { header: true });
if (parsed.data == null || !Array.isArray(parsed.data)) return null;
const rows = parsed.data;
const match = rows.find((row) => row['urls.direct_download'] === feedUrl);
return typeof match?.id === 'string' ? match.id : null;
} catch (e) {
return null;
}
}

/**
* FeedCsvRow interface with fields
*/
interface FeedCsvRow {
'urls.direct_download'?: string;
id: string;
}
5 changes: 5 additions & 0 deletions web-app/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11600,6 +11600,11 @@ pac-resolver@^7.0.0:
ip "^1.1.8"
netmask "^2.0.2"

papaparse@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.3.tgz#07f8994dec516c6dab266e952bed68e1de59fa9a"
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==

param-case@^3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz"
Expand Down
Loading