diff --git a/web-app/package.json b/web-app/package.json
index ba24b972c..0c07623fb 100644
--- a/web-app/package.json
+++ b/web-app/package.json
@@ -92,7 +92,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/cypress": "^1.1.3",
- "@types/jest": "^27.5.2",
+ "@types/jest": "^29.5.12",
"@types/material-ui": "^0.21.12",
"@types/node": "^20.8.10",
"@types/react": "^18.2.25",
diff --git a/web-app/public/locales/en/common.json b/web-app/public/locales/en/common.json
index 61e169649..49fe2127b 100644
--- a/web-app/public/locales/en/common.json
+++ b/web-app/public/locales/en/common.json
@@ -18,5 +18,10 @@
"loading": "Loading...",
"errors": {
"generic": "We are unable to complete your request at the moment."
- }
+ },
+ "others": "others",
+ "apiKey": "API Key",
+ "httpHeader": "HTTP Header",
+ "back": "Back",
+ "and": "and"
}
\ No newline at end of file
diff --git a/web-app/public/locales/en/feeds.json b/web-app/public/locales/en/feeds.json
index 93dc277d8..e81c63c11 100644
--- a/web-app/public/locales/en/feeds.json
+++ b/web-app/public/locales/en/feeds.json
@@ -16,10 +16,27 @@
"checkSpelling": "Double check the spelling"
},
"errorAndContact": "Please check your internet connection and try again. If the problem persists <1>contact us1> for for further assistance.",
+ "errorLoadingFeed": "There was an error loading the feed.",
"form": {
"addOrUpdateFeed": "Add or Update a Feed",
"signUp": "Sign up for a Mobility Database account or login to add or update a GTFS feed.",
"signUpAction": "Sign up for an account",
"loginSuccess": "You were successfully logged in, you can now add or update a feed."
- }
+ },
+ "seeFullList": "See full list",
+ "hideFullList": "Hide full list",
+ "producerDownloadUrl": "Producer download URL",
+ "copyDownloadUrl": "Copy download URL",
+ "producerUrlCopied": "Producer url copied to clipboard",
+ "authenticationType": "Authentication type",
+ "registerToDownloadFeed": "Register to download feed",
+ "feedContactEmail": "Feed contact email",
+ "copyFeedContactEmail": "Copy feed contact email",
+ "features": "Features",
+ "unableToDownloadFeed": "Unable to download this feed. If there is a more recent URL forthis feed, <1>please submit it here1>",
+ "feedHasBeenReplaced": "This feed has been replaced with a different producer URL.{' '}<1> Go to the new feed here1>.",
+ "downloadLatest": "Download Latest",
+ "seeLicense": "See License",
+ "boundingBoxTitle": "Bounding box from stops.txt",
+ "unableToGenerateBoundingBox": "Unable to generate bounding box."
}
diff --git a/web-app/src/app/screens/Feed/AssociatedFeeds.tsx b/web-app/src/app/screens/Feed/AssociatedFeeds.tsx
index 893dc8182..d6c424eec 100644
--- a/web-app/src/app/screens/Feed/AssociatedFeeds.tsx
+++ b/web-app/src/app/screens/Feed/AssociatedFeeds.tsx
@@ -123,6 +123,7 @@ export default function AssociatedGTFSRTFeeds({
width={{ xs: '100%' }}
title={'Related Schedule Feeds'}
outlineColor={colors.indigo[500]}
+ margin={'0 0 8px'}
>
{feeds === undefined && Loading...}
{feeds !== undefined && gtfsFeeds?.length === 0 && (
diff --git a/web-app/src/app/screens/Feed/Feed.spec.tsx b/web-app/src/app/screens/Feed/Feed.spec.tsx
new file mode 100644
index 000000000..47bdc873c
--- /dev/null
+++ b/web-app/src/app/screens/Feed/Feed.spec.tsx
@@ -0,0 +1,140 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { formatProvidersSorted, getFeedTitleElement } from '.';
+import {
+ type GTFSFeedType,
+ type GTFSRTFeedType,
+} from '../../services/feeds/utils';
+import { type TFunction } from 'i18next';
+
+const mockFeed: GTFSFeedType = {
+ id: 'mdb-x',
+ data_type: 'gtfs',
+ status: 'active',
+ created_at: undefined,
+ external_ids: [
+ {
+ external_id: 'x',
+ source: 'mdb',
+ },
+ ],
+ provider: 'DPN, AVL, CFL, CFLBus, RGTR, TICE, TRAM',
+ feed_name: 'Aggregated Luxembourg - OpenOV',
+ note: '',
+ feed_contact_email: '',
+ source_info: {
+ producer_url: 'http://fake.zip',
+ authentication_type: 0,
+ authentication_info_url: '',
+ api_key_parameter_name: '',
+ license_url: 'http://fake/LICENSE.TXT',
+ },
+ locations: [
+ {
+ country_code: 'BE',
+ },
+ {
+ country_code: 'DE',
+ },
+ {
+ country_code: 'FR',
+ },
+ ],
+ latest_dataset: {
+ id: '1',
+ hosted_url: 'https://fake.zip',
+ downloaded_at: '2024-07-03T17:38:24.963131Z',
+ hash: 'x',
+ },
+};
+
+const mockFeedOneProvider = {
+ ...mockFeed,
+ provider: 'AVL',
+};
+
+const mockFeedRT: GTFSRTFeedType = {
+ id: 'mdb-x',
+ data_type: 'gtfs_rt',
+ status: 'active',
+ external_ids: [
+ {
+ external_id: 'x',
+ source: 'mdb',
+ },
+ ],
+ provider:
+ 'SeaLink Pine Harbour, Waikato Regional Council, Pavlovich Transport Solutions, AT Metro',
+ feed_name: 'Auckland Transport Developer',
+ note: '',
+ feed_contact_email: '',
+ source_info: {
+ producer_url: 'https://api.fake/vehiclelocations',
+ authentication_type: 2,
+ authentication_info_url: 'https://fake.govt.nz/',
+ api_key_parameter_name: 'sub',
+ license_url: 'https://fake/',
+ },
+ locations: [
+ {
+ country_code: 'NZ',
+ },
+ ],
+ entity_types: ['vp'],
+ feed_references: ['mdb-y'],
+};
+
+describe('Feed page', () => {
+ afterEach(cleanup);
+
+ it('should format the providers correctly', () => {
+ const formattedProviders = formatProvidersSorted(mockFeed?.provider ?? '');
+ expect(formattedProviders).toEqual([
+ 'AVL',
+ 'CFL',
+ 'CFLBus',
+ 'DPN',
+ 'RGTR',
+ 'TICE',
+ 'TRAM',
+ ]);
+ });
+
+ it('should format the page title correctly when there are more than one and gtfs', () => {
+ const mockT = jest.fn((key) => key) as unknown as TFunction<
+ 'feeds',
+ undefined
+ >;
+ const formattedProviders = formatProvidersSorted(mockFeed?.provider ?? '');
+ render(getFeedTitleElement(formattedProviders, mockFeed, mockT));
+ expect(screen.getByText('AVL')).toBeTruthy();
+ expect(screen.getByText('+6 common:others')).toBeTruthy();
+ });
+
+ it('should format the page title correctly when there are more than one and gtfs_rt', () => {
+ const mockT = jest.fn((key) => key) as unknown as TFunction<
+ 'feeds',
+ undefined
+ >;
+ const formattedProviders = formatProvidersSorted(
+ mockFeedRT?.provider ?? '',
+ );
+ render(getFeedTitleElement(formattedProviders, mockFeedRT, mockT));
+ expect(
+ screen.getByText('AT Metro - Auckland Transport Developer'),
+ ).toBeTruthy();
+ expect(screen.getByText('+3 common:others')).toBeTruthy();
+ });
+
+ it('should format the page title correctly when there is only one provider', () => {
+ const mockT = jest.fn((key) => key) as unknown as TFunction<
+ 'feeds',
+ undefined
+ >;
+ const formattedProviders = formatProvidersSorted(
+ mockFeedOneProvider?.provider ?? '',
+ );
+ render(getFeedTitleElement(formattedProviders, mockFeedOneProvider, mockT));
+ expect(screen.getByText('AVL')).toBeTruthy();
+ expect(screen.queryByText('+')).toBeNull();
+ });
+});
diff --git a/web-app/src/app/screens/Feed/FeedSummary.tsx b/web-app/src/app/screens/Feed/FeedSummary.tsx
index 4345adacb..107a9e02d 100644
--- a/web-app/src/app/screens/Feed/FeedSummary.tsx
+++ b/web-app/src/app/screens/Feed/FeedSummary.tsx
@@ -9,6 +9,7 @@ import {
Typography,
colors,
Snackbar,
+ styled,
} from '@mui/material';
import { ContentCopy, ContentCopyOutlined } from '@mui/icons-material';
import {
@@ -16,9 +17,11 @@ import {
type GTFSRTFeedType,
} from '../../services/feeds/utils';
import { type components } from '../../services/feeds/types';
+import { useTranslation } from 'react-i18next';
export interface FeedSummaryProps {
feed: GTFSFeedType | GTFSRTFeedType | undefined;
+ sortedProviders: string[];
latestDataset?: components['schemas']['GtfsDataset'] | undefined;
width: Record;
}
@@ -29,12 +32,28 @@ const boxElementStyle: SxProps = {
mb: 1,
};
+const ResponsiveListItem = styled('li')(({ theme }) => ({
+ width: '100%',
+ margin: '5px 0',
+ fontWeight: 'normal',
+ fontSize: '16px',
+ [theme.breakpoints.up('lg')]: {
+ width: 'calc(50% - 15px)',
+ },
+}));
+
export default function FeedSummary({
feed,
+ sortedProviders,
latestDataset,
width,
}: FeedSummaryProps): React.ReactElement {
+ const { t } = useTranslation('feeds');
const [snackbarOpen, setSnackbarOpen] = React.useState(false);
+ const [showAllProviders, setShowAllProviders] = React.useState(false);
+ const providersToDisplay = showAllProviders
+ ? sortedProviders
+ : sortedProviders.slice(0, 4);
const hasAuthenticationInfo =
feed?.source_info?.authentication_info_url !== undefined &&
@@ -53,7 +72,7 @@ export default function FeedSummary({
gutterBottom
sx={{ fontWeight: 'bold' }}
>
- Location
+ {t('location')}
{feed?.locations !== undefined
@@ -70,7 +89,57 @@ export default function FeedSummary({
gutterBottom
sx={{ fontWeight: 'bold' }}
>
- Producer download URL
+ {t('transitProvider')}
+
+
+
+ {providersToDisplay.map((provider) => (
+ {provider}
+ ))}
+
+
+ {!showAllProviders && sortedProviders.length > 4 && (
+
+ )}
+
+ {showAllProviders && (
+
+ )}
+
+
+
+
+ {t('producerDownloadUrl')}
)}
{
if (feed?.source_info?.producer_url !== undefined) {
@@ -107,7 +176,7 @@ export default function FeedSummary({
onClose={() => {
setSnackbarOpen(false);
}}
- message='Producer url copied to clipboard'
+ message={t('producerUrlCopied')}
/>
@@ -118,11 +187,11 @@ export default function FeedSummary({
gutterBottom
sx={{ fontWeight: 'bold' }}
>
- Data type
+ {t('dataType')}
- {feed?.data_type === 'gtfs' && 'GTFS Schedule'}
- {feed?.data_type === 'gtfs_rt' && 'GTFS Realtime'}
+ {feed?.data_type === 'gtfs' && t('common:gtfsSchedule')}
+ {feed?.data_type === 'gtfs_rt' && t('common:gtfsRealtime')}
@@ -132,11 +201,12 @@ export default function FeedSummary({
gutterBottom
sx={{ fontWeight: 'bold' }}
>
- Authentication type
+ {t('authenticationType')}
- {feed?.source_info?.authentication_type === 1 && 'API Key'}
- {feed?.source_info?.authentication_type === 2 && 'HTTP Header'}
+ {feed?.source_info?.authentication_type === 1 && t('common:apiKey')}
+ {feed?.source_info?.authentication_type === 2 &&
+ t('common:httpHeader')}
@@ -148,7 +218,7 @@ export default function FeedSummary({
className='btn-link'
rel='noreferrer'
>
- Register to download this feed
+ {t('registerToDownloadFeed')}
)}
@@ -162,7 +232,7 @@ export default function FeedSummary({
gutterBottom
sx={{ fontWeight: 'bold' }}
>
- Feed contact email:
+ {t('feedContactEmail')}:
{feed?.feed_contact_email !== undefined &&
feed?.feed_contact_email.length > 0 && (
@@ -174,7 +244,7 @@ export default function FeedSummary({
focusRipple={false}
endIcon={
{
if (feed?.feed_contact_email !== undefined) {
@@ -199,7 +269,7 @@ export default function FeedSummary({
gutterBottom
sx={{ fontWeight: 'bold' }}
>
- Features
+ {t('features')}
{latestDataset.validation_report?.features?.map((feature) => (
diff --git a/web-app/src/app/screens/Feed/index.tsx b/web-app/src/app/screens/Feed/index.tsx
index a05748178..1c6cdfbce 100644
--- a/web-app/src/app/screens/Feed/index.tsx
+++ b/web-app/src/app/screens/Feed/index.tsx
@@ -43,11 +43,73 @@ import FeedSummary from './FeedSummary';
import DataQualitySummary from './DataQualitySummary';
import AssociatedFeeds from './AssociatedFeeds';
import { WarningContentBox } from '../../components/WarningContentBox';
+import {
+ type GTFSFeedType,
+ type GTFSRTFeedType,
+} from '../../services/feeds/utils';
+import { Trans, useTranslation } from 'react-i18next';
+import { type TFunction } from 'i18next';
+
+export function formatProvidersSorted(provider: string): string[] {
+ const providers = provider.split(',').filter((n) => n);
+ const providersTrimmed = providers.map((p) => p.trim());
+ const providersSorted = providersTrimmed.sort();
+ return providersSorted;
+}
+
+export function getFeedTitleElement(
+ sortedProviders: string[],
+ feed: GTFSFeedType | GTFSRTFeedType,
+ translationFunction: TFunction<'feeds', undefined>,
+): JSX.Element {
+ const mainProvider = sortedProviders[0];
+ let extraProviders: string | undefined;
+ let realtimeFeedName: string | undefined;
+ if (sortedProviders.length > 1) {
+ extraProviders =
+ '+' +
+ (sortedProviders.length - 1) +
+ ' ' +
+ translationFunction('common:others');
+ }
+ if (
+ feed?.data_type === 'gtfs_rt' &&
+ feed?.feed_name !== undefined &&
+ feed?.feed_name !== ''
+ ) {
+ realtimeFeedName = ` - ${feed?.feed_name}`;
+ }
+ return (
+
+ {mainProvider + (realtimeFeedName ?? '')}
+ {extraProviders !== undefined && (
+
+ {extraProviders}
+
+ )}
+
+ );
+}
const wrapComponent = (
feedLoadingStatus: string,
child: React.ReactElement,
): React.ReactElement => {
+ const { t } = useTranslation('feeds');
return (
@@ -67,9 +129,7 @@ const wrapComponent = (
mt: 4,
}}
>
- {feedLoadingStatus === 'error' && (
- <>There was an error loading the feed.>
- )}
+ {feedLoadingStatus === 'error' && <>{t('errorLoadingFeed')}>}
{feedLoadingStatus !== 'error' ? child : null}
@@ -78,12 +138,18 @@ const wrapComponent = (
};
export default function Feed(): React.ReactElement {
+ const { t } = useTranslation('feeds');
const dispatch = useAppDispatch();
const { feedId } = useParams();
const user = useSelector(selectUserProfile);
const feedLoadingStatus = useSelector(selectFeedLoadingStatus);
const datasetLoadingStatus = useSelector(selectDatasetsLoadingStatus);
const feedType = useSelector(selectFeedData)?.data_type;
+ const relatedFeeds = useSelector(selectRelatedFeedsData);
+ const relatedGtfsRtFeeds = useSelector(selectRelatedGtfsRTFeedsData);
+ const datasets = useSelector(selectDatasetsData);
+ const latestDataset = useSelector(selectLatestDatasetsData);
+ const boundingBox = useSelector(selectBoundingBoxFromLatestDataset);
const feed =
feedType === 'gtfs'
? useSelector(selectGTFSFeedData)
@@ -91,6 +157,7 @@ export default function Feed(): React.ReactElement {
const needsToLoadFeed = feed === undefined || feed?.id !== feedId;
const isAuthenticatedOrAnonymous =
useSelector(selectIsAuthenticated) || useSelector(selectIsAnonymous);
+ const sortedProviders = formatProvidersSorted(feed?.provider ?? '');
useEffect(() => {
if (user !== undefined && feedId !== undefined && needsToLoadFeed) {
@@ -104,8 +171,8 @@ export default function Feed(): React.ReactElement {
return;
}
let newDocTitle = 'Mobility Database';
- if (feed?.provider !== undefined) {
- newDocTitle += ` | ${feed?.provider}`;
+ if (sortedProviders[0] !== undefined) {
+ newDocTitle += ` | ${sortedProviders[0]}`;
}
if (feed?.feed_name !== undefined) {
newDocTitle += ` | ${feed?.feed_name}`;
@@ -134,15 +201,9 @@ export default function Feed(): React.ReactElement {
};
}, [feed, needsToLoadFeed]);
- const relatedFeeds = useSelector(selectRelatedFeedsData);
- const relatedGtfsRtFeeds = useSelector(selectRelatedGtfsRTFeedsData);
- const datasets = useSelector(selectDatasetsData);
- const latestDataset = useSelector(selectLatestDatasetsData);
- const boundingBox = useSelector(selectBoundingBoxFromLatestDataset);
-
// The feedId parameter doesn't match the feedId in the store, so we need to load the feed and only render the loading message.
if (needsToLoadFeed) {
- return wrapComponent(feedLoadingStatus, Loading...);
+ return wrapComponent(feedLoadingStatus, {t('common:loading')});
}
const hasDatasets = datasets !== undefined && datasets.length > 0;
const hasFeedRedirect =
@@ -171,7 +232,7 @@ export default function Feed(): React.ReactElement {
>
{' '}
- Back
+ {t('common:back')}
@@ -182,26 +243,18 @@ export default function Feed(): React.ReactElement {
},
}}
>
- Feeds /{' '}
+ {t('common:feeds')} /{' '}
- {feed?.data_type === 'gtfs' ? 'GTFS Schedule' : 'GTFS Realtime'}
+ {feed?.data_type === 'gtfs'
+ ? t('common:gtfsSchedule')
+ : t('common:gtfsRealtime')}
{' '}
/ {feed?.id}
-
- {feed?.provider?.substring(0, 100)}
- {feed?.data_type === 'gtfs_rt' && ` - ${feed?.feed_name}`}
-
+ {getFeedTitleElement(sortedProviders, feed, t)}
{feed !== undefined &&
feed.feed_name !== '' &&
@@ -235,12 +288,12 @@ export default function Feed(): React.ReactElement {
.map(
(entityType) =>
({
- tu: 'Trip Updates',
- vp: 'Vehicle Positions',
- sa: 'Service Alerts',
+ tu: t('common:gtfsRealtimeEntities.tripUpdates'),
+ vp: t('common:gtfsRealtimeEntities.vehiclePositions'),
+ sa: t('common:gtfsRealtimeEntities.serviceAlerts'),
})[entityType],
)
- .join(' and ')}
+ .join(' ' + t('common:and') + ' ')}
)}
@@ -250,19 +303,23 @@ export default function Feed(): React.ReactElement {
!hasFeedRedirect && (
- Unable to download this feed. If there is a more recent URL for
- this feed, please submit it here
+
+ Unable to download this feed. If there is a more recent URL for
+ this feed, please submit it here
+
)}
{hasFeedRedirect && (
- This feed has been replaced with a different producer URL.{' '}
-
- Go to the new feed here
-
- .
+
+ This feed has been replaced with a different producer URL.{' '}
+
+ Go to the new feed here
+
+ .
+
)}
@@ -276,7 +333,7 @@ export default function Feed(): React.ReactElement {
rel='noreferrer'
id='download-latest-button'
>
- Download Latest
+ {t('downloadLatest')}
)}
@@ -293,7 +350,7 @@ export default function Feed(): React.ReactElement {
className='btn-link'
rel='noreferrer'
>
- See License
+ {t('seeLicense')}
)}
@@ -311,14 +368,14 @@ export default function Feed(): React.ReactElement {
>
{feed?.data_type === 'gtfs' && (
{boundingBox === undefined && (
- Unable to generate bounding box.
+ {t('unableToGenerateBoundingBox')}
)}
{boundingBox !== undefined && (
@@ -331,6 +388,7 @@ export default function Feed(): React.ReactElement {
)}
diff --git a/web-app/src/app/screens/Feeds/SearchTable.spec.tsx b/web-app/src/app/screens/Feeds/SearchTable.spec.tsx
new file mode 100644
index 000000000..0ee2419a2
--- /dev/null
+++ b/web-app/src/app/screens/Feeds/SearchTable.spec.tsx
@@ -0,0 +1,165 @@
+import SearchTable, { getDataTypeElement } from './SearchTable';
+import { render, cleanup, screen, within } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { type AllFeedsType } from '../../services/feeds/utils';
+
+const mockFeedsData: AllFeedsType = {
+ total: 2004,
+ results: [
+ {
+ id: '3',
+ data_type: 'gtfs',
+ status: 'active',
+ created_at: undefined,
+ external_ids: [
+ {
+ external_id: '170',
+ source: 'mdb',
+ },
+ ],
+ provider: 'Utah Transit Authority (UTA)',
+ feed_name: '',
+ note: '',
+ feed_contact_email: '',
+ source_info: {
+ producer_url: 'https://fake/GTFS.zip',
+ authentication_type: 0,
+ authentication_info_url: '',
+ api_key_parameter_name: '',
+ license_url: 'http://fake/TermsOfUse.aspx',
+ },
+ redirects: undefined,
+ locations: [
+ {
+ country_code: 'US',
+ subdivision_name: 'Utah',
+ municipality: undefined,
+ },
+ ],
+ latest_dataset: {
+ id: '1',
+ hosted_url: 'fake.zip',
+ bounding_box: undefined,
+ downloaded_at: '2024-07-24T18:32:53.952458Z',
+ hash: 'd',
+ validation_report: undefined,
+ },
+ entity_types: undefined,
+ feed_references: undefined,
+ },
+ {
+ id: 'mdb-1003',
+ data_type: 'gtfs',
+ status: 'active',
+ created_at: undefined,
+ external_ids: [
+ {
+ external_id: '1003',
+ source: 'mdb',
+ },
+ ],
+ provider: 'TRAM',
+ feed_name: '',
+ note: '',
+ feed_contact_email: '',
+ source_info: {
+ producer_url: 'https://fake/GTFS/zip/TBX.zip',
+ authentication_type: 0,
+ authentication_info_url: '',
+ api_key_parameter_name: '',
+ license_url: '',
+ },
+ redirects: undefined,
+ locations: [
+ {
+ country_code: 'ES',
+ subdivision_name: 'Catalonia',
+ municipality: undefined,
+ },
+ ],
+ latest_dataset: {
+ id: '12',
+ hosted_url: 'https://fa.zip',
+ bounding_box: undefined,
+ downloaded_at: '2024-07-24T18:38:29.574211Z',
+ hash: 'g',
+ validation_report: undefined,
+ },
+ entity_types: undefined,
+ feed_references: undefined,
+ },
+ {
+ id: 'g',
+ data_type: 'gtfs',
+ status: 'inactive',
+ created_at: undefined,
+ external_ids: [
+ {
+ external_id: '595',
+ source: 'mdb',
+ },
+ ],
+ provider:
+ 'Alcatraz Cruises - Hornblower, Angel Island Tiburon Ferry, Blue & Gold Fleet',
+ feed_name: '',
+ note: '',
+ feed_contact_email: '',
+ source_info: {
+ producer_url: 'http://fakef.zip',
+ authentication_type: 0,
+ authentication_info_url: '',
+ api_key_parameter_name: '',
+ license_url: '',
+ },
+ redirects: undefined,
+ locations: [
+ {
+ country_code: 'US',
+ subdivision_name: 'California',
+ municipality: 'San Francisco',
+ },
+ ],
+ latest_dataset: {
+ id: '24',
+ hosted_url: 'https://fake.zip',
+ bounding_box: undefined,
+ downloaded_at: '2024-06-18T19:55:27.794061Z',
+ hash: 'hg',
+ validation_report: undefined,
+ },
+ entity_types: undefined,
+ feed_references: undefined,
+ },
+ ],
+};
+
+describe.only('getProviderElement', () => {
+ afterEach(cleanup);
+
+ it('should display the correct number of transit providers in table row', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Utah Transit Authority (UTA)')).toBeTruthy();
+ const parentElement = screen.getByText('Angel Island Tiburon Ferry');
+ const spanElement = within(parentElement).getByText('+ 2', {
+ selector: 'a',
+ });
+ expect(parentElement).toBeTruthy();
+ expect(spanElement).toBeTruthy();
+ expect(screen.queryByText('Alcatraz Cruises - Hornblower')).toBeNull();
+ });
+
+ it('should display the correct data type depending on the feed type', () => {
+ const { getByText } = render(getDataTypeElement('gtfs'));
+ expect(getByText('common:gtfsSchedule')).toBeInTheDocument();
+ });
+
+ it('should display the correct data type depending on the feed type', () => {
+ const { getByText } = render(getDataTypeElement('gtfs_rt'));
+ expect(getByText('common:gtfsRealtime')).toBeInTheDocument();
+ });
+});
diff --git a/web-app/src/app/screens/Feeds/SearchTable.tsx b/web-app/src/app/screens/Feeds/SearchTable.tsx
index 792d6d797..761ba83ff 100644
--- a/web-app/src/app/screens/Feeds/SearchTable.tsx
+++ b/web-app/src/app/screens/Feeds/SearchTable.tsx
@@ -2,14 +2,22 @@ import * as React from 'react';
import {
Box,
Chip,
+ IconButton,
+ Popover,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
+ Typography,
+ colors,
styled,
} from '@mui/material';
-import { type AllFeedsType } from '../../services/feeds/utils';
+import {
+ type GTFSFeedType,
+ type GTFSRTFeedType,
+ type AllFeedsType,
+} from '../../services/feeds/utils';
import { type FeedLocations } from '../../types';
import BusAlertIcon from '@mui/icons-material/BusAlert';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
@@ -17,6 +25,7 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import GtfsRtEntities from './GtfsRtEntities';
+import CloseIcon from '@mui/icons-material/Close';
export interface SearchTableProps {
feedsData: AllFeedsType | undefined;
@@ -28,7 +37,9 @@ const HeaderTableCell = styled(TableCell)(() => ({
border: 'none',
}));
-const getDataTypeElement = (dataType: 'gtfs' | 'gtfs_rt'): JSX.Element => {
+export const getDataTypeElement = (
+ dataType: 'gtfs' | 'gtfs_rt',
+): JSX.Element => {
const { t } = useTranslation('feeds');
const DataTypeHolder = ({
children,
@@ -64,10 +75,6 @@ const getDataTypeElement = (dataType: 'gtfs' | 'gtfs_rt'): JSX.Element => {
}
};
-const getProviderName = (provider: string): string => {
- return provider.split(',')[0];
-};
-
const getLocationName = (locations: FeedLocations): string => {
if (locations?.[0] === undefined) {
return '';
@@ -90,9 +97,59 @@ const getLocationName = (locations: FeedLocations): string => {
export default function SearchTable({
feedsData,
}: SearchTableProps): React.ReactElement {
+ const [providersPopoverData, setProvidersPopoverData] = React.useState<
+ string[] | undefined
+ >(undefined);
const { t } = useTranslation('feeds');
if (feedsData === undefined) return <>>;
const navigate = useNavigate();
+
+ const getProviderElement = (
+ feed: GTFSFeedType | GTFSRTFeedType,
+ ): JSX.Element => {
+ const providers =
+ feed?.provider
+ ?.split(',')
+ .filter((x) => x)
+ .sort() ?? [];
+ const displayName = providers[0];
+ let manyProviders: JSX.Element | undefined;
+ if (providers.length > 1) {
+ manyProviders = (
+ {
+ event.stopPropagation();
+ setProvidersPopoverData(providers);
+ }}
+ >
+ + {providers.length - 1}
+
+ );
+ }
+ return (
+ <>
+ {displayName} {manyProviders}
+ {feed?.status === 'deprecated' && (
+
+ }
+ color='error'
+ size='small'
+ variant='outlined'
+ />
+
+ )}
+ >
+ );
+ };
+
return (
-
- {getProviderName(feed.provider ?? '')}
- {feed.status === 'deprecated' && (
-
- }
- color='error'
- size='small'
- variant='outlined'
- />
-
- )}
-
+ {getProviderElement(feed)}
{getLocationName(feed.locations)}
{feed.feed_name}
@@ -189,6 +233,50 @@ export default function SearchTable({
))}
+ {providersPopoverData !== undefined && (
+ {
+ setProvidersPopoverData(undefined);
+ }}
+ anchorReference='none'
+ sx={{
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ }}
+ >
+
+
+ Transit Providers - {providersPopoverData[0]}
+
+ {
+ setProvidersPopoverData(undefined);
+ }}
+ >
+
+
+
+
+
+
+ {providersPopoverData.map((provider) => (
+ - {provider}
+ ))}
+
+
+
+ )}
);
}
diff --git a/web-app/src/setupTests.ts b/web-app/src/setupTests.ts
index 8f2609b7b..a54445c51 100644
--- a/web-app/src/setupTests.ts
+++ b/web-app/src/setupTests.ts
@@ -3,3 +3,22 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+
+jest.mock('leaflet/dist/leaflet.css', () => ({}));
+jest.mock('react-leaflet', () => ({}));
+
+jest.mock('react-i18next', () => ({
+ // this mock makes sure any components using the translate hook can use it without a warning being shown
+ useTranslation: () => {
+ return {
+ t: (str: string) => str,
+ i18n: {
+ changeLanguage: () => new Promise(() => {}),
+ },
+ };
+ },
+ initReactI18next: {
+ type: '3rdParty',
+ init: () => {},
+ },
+}));
diff --git a/web-app/tsconfig.json b/web-app/tsconfig.json
index 377924786..bea049b93 100644
--- a/web-app/tsconfig.json
+++ b/web-app/tsconfig.json
@@ -19,7 +19,7 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
- "types": ["node"],
+ "types": ["node", "jest"],
"paths": {
"react": [ "./node_modules/@types/react" ]
}
diff --git a/web-app/yarn.lock b/web-app/yarn.lock
index 2c7ad4849..960ce4be1 100644
--- a/web-app/yarn.lock
+++ b/web-app/yarn.lock
@@ -3293,13 +3293,13 @@
expect "^29.0.0"
pretty-format "^29.0.0"
-"@types/jest@^27.5.2":
- version "27.5.2"
- resolved "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz"
- integrity sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==
+"@types/jest@^29.5.12":
+ version "29.5.12"
+ resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544"
+ integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==
dependencies:
- jest-matcher-utils "^27.0.0"
- pretty-format "^27.0.0"
+ expect "^29.0.0"
+ pretty-format "^29.0.0"
"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
version "7.0.15"
@@ -9058,7 +9058,7 @@ jest-leak-detector@^27.5.1:
jest-get-type "^27.5.1"
pretty-format "^27.5.1"
-jest-matcher-utils@^27.0.0, jest-matcher-utils@^27.5.1:
+jest-matcher-utils@^27.5.1:
version "27.5.1"
resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz"
integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==
@@ -11661,7 +11661,7 @@ pretty-error@^4.0.0:
lodash "^4.17.20"
renderkid "^3.0.0"
-pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.5.1:
+pretty-format@^27.0.2, pretty-format@^27.5.1:
version "27.5.1"
resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz"
integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==
@@ -13145,7 +13145,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
-"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -13228,7 +13237,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -14660,7 +14676,7 @@ workbox-window@6.6.1:
"@types/trusted-types" "^2.0.2"
workbox-core "6.6.1"
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -14678,6 +14694,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
+wrap-ansi@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"