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 us 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 here", + "feedHasBeenReplaced": "This feed has been replaced with a different producer URL.{' '}<1> Go to the new feed here.", + "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')} + + + + + {!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"