Skip to content

Commit 4adc818

Browse files
feat: external id to feed summary ui (#1488)
* external id to feed summary * co-pilot pr comments * external id docs url added as external link * sticky map styling * added doc link for external id
1 parent 5381453 commit 4adc818

File tree

6 files changed

+225
-2
lines changed

6 files changed

+225
-2
lines changed

web-app/public/locales/en/feeds.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@
194194
"fullMapView": {
195195
"disabledTitle": "Full map view disabled",
196196
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
197-
"dataBlurb":"The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn’t specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they’re combined into one for display.",
197+
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn’t specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they’re combined into one for display.",
198198
"clearAll": "Clear All",
199199
"hideStops": "Hide Stops",
200200
"closePanel": "Close",
@@ -223,5 +223,15 @@
223223
"radius": "Radius: {{px}}px"
224224
}
225225
},
226-
"relatedLinks": "Related Links"
226+
"relatedLinks": "Related Links",
227+
"externalIds": {
228+
"title": "External Identifiers",
229+
"tooltips": {
230+
"jbda": "Automatically imported from http://docs.gtfs-data.jp/api.v2.html. Pattern is jbda-<organisation_id>-<feed_id>",
231+
"tdg": "Automatically imported from https://doc.transport.data.gouv.fr/outils/outils-disponibles-sur-le-pan/api. Pattern is tdg-<resource_id>",
232+
"ntd": "Automatically imported from https://www.transit.dot.gov/ntd/data-product/2023-annual-database-general-transit-feed-specification-gtfs-weblinks. Pattern is ntd-<ntd_id>",
233+
"tfs": "Automatically imported from old TransitFeeds website. Pattern is tfs-<feed_id>",
234+
"tld": "Imported from https://www.transit.land/documentation/rest-api/feeds. Pattern is tld-<feed_id>"
235+
}
236+
}
227237
}

web-app/src/app/components/CoveredAreaMap.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ const CoveredAreaMap: React.FC<CoveredAreaMapProps> = ({
270270
return (
271271
<Box
272272
sx={{
273+
position: 'sticky',
274+
top: '74px',
273275
flexGrow: 1,
274276
display: 'flex',
275277
flexDirection: 'column',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React from 'react';
2+
import {
3+
Box,
4+
IconButton,
5+
Tooltip,
6+
Typography,
7+
Link,
8+
Button,
9+
} from '@mui/material';
10+
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
11+
import { type components } from '../../../services/feeds/types';
12+
import {
13+
externalIdSourceMap,
14+
filterFeedExternalIdsToSourceMap,
15+
} from '../../../utils/externalIds';
16+
import { useTranslation } from 'react-i18next';
17+
18+
type ExternalIdInfo = components['schemas']['ExternalIds'];
19+
20+
export interface ExternalIdsProps {
21+
externalIds: ExternalIdInfo | undefined;
22+
}
23+
24+
export default function ExternalIds({
25+
externalIds,
26+
}: ExternalIdsProps): React.ReactElement | null {
27+
const { t } = useTranslation('feeds');
28+
if (externalIds == null || externalIds.length === 0) return null;
29+
const filteredExternalIds = filterFeedExternalIdsToSourceMap(externalIds);
30+
if (filteredExternalIds.length === 0) return null;
31+
return (
32+
<Box sx={{ mt: 2, mb: 0 }}>
33+
<Typography
34+
variant='subtitle2'
35+
sx={{ fontWeight: 700, color: 'text.secondary' }}
36+
>
37+
External IDs
38+
</Typography>
39+
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
40+
{filteredExternalIds.map((externalId, idx) => {
41+
const src = externalId.source.toLowerCase();
42+
const info = externalIdSourceMap[src];
43+
return (
44+
<Box
45+
key={idx}
46+
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
47+
>
48+
<Button
49+
variant='text'
50+
sx={{
51+
fontWeight: 700,
52+
minWidth: 'auto',
53+
color: 'text.primary',
54+
textTransform: 'none',
55+
p: 0,
56+
px: 1.5,
57+
ml: -1.5,
58+
fontSize: 'medium',
59+
}}
60+
component={Link}
61+
href={info.docsUrl}
62+
target='_blank'
63+
rel='noopener noreferrer'
64+
>
65+
{info?.label ?? externalId.source}
66+
</Button>
67+
<Typography
68+
variant='body1'
69+
sx={{ wordBreak: 'break-all', lineHeight: 1.2 }}
70+
>
71+
{externalId.external_id}
72+
</Typography>
73+
<Tooltip title={t(info.translationKey)} placement='top'>
74+
{info.docsUrl == null ? (
75+
<InfoOutlinedIcon
76+
sx={{
77+
p: '5px',
78+
boxSizing: 'content-box',
79+
fontSize: '1.125rem',
80+
}}
81+
/>
82+
) : (
83+
<IconButton
84+
size='small'
85+
color='primary'
86+
component={Link}
87+
href={info.docsUrl}
88+
target='_blank'
89+
rel='noopener noreferrer'
90+
>
91+
<InfoOutlinedIcon fontSize='inherit' />
92+
</IconButton>
93+
)}
94+
</Tooltip>
95+
</Box>
96+
);
97+
})}
98+
</Box>
99+
</Box>
100+
);
101+
}

web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { getFeatureComponentDecorators } from '../../../utils/consts';
5454
import Locations from '../../../components/Locations';
5555
import CopyLinkElement from './CopyLinkElement';
5656
import { formatDateShort } from '../../../utils/date';
57+
import ExternalIds from './ExternalIds';
5758

5859
export interface GtfsFeedSummaryProps {
5960
feed: GTFSFeedType | GTFSRTFeedType | undefined;
@@ -152,6 +153,10 @@ export default function GtfsFeedSummary({
152153
</Typography>
153154
</Tooltip>
154155
)}
156+
157+
{feed?.external_ids != null && feed.external_ids.length > 0 && (
158+
<ExternalIds externalIds={feed.external_ids} />
159+
)}
155160
</Box>
156161
</GroupCard>
157162

@@ -623,6 +628,7 @@ export default function GtfsFeedSummary({
623628
))}
624629
</GroupCard>
625630
)}
631+
626632
<Dialog
627633
fullScreen={fullScreen}
628634
maxWidth='md'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
filterFeedExternalIdsToSourceMap,
3+
externalIdSourceMap,
4+
type ExternalIdInfo,
5+
} from './externalIds';
6+
7+
describe('filterFeedExternalIdsToSourceMap', () => {
8+
it('should keep only entries with known sources and external_id present', () => {
9+
const input = [
10+
{ source: 'jbda', external_id: 'jbda-1-2' },
11+
{ source: 'unknown', external_id: 'x' },
12+
{ source: 'tld', external_id: 'tld-abc' },
13+
];
14+
15+
const out = filterFeedExternalIdsToSourceMap(input);
16+
expect(out).toHaveLength(2);
17+
expect(out).toEqual(
18+
expect.arrayContaining([
19+
expect.objectContaining({ source: 'jbda', external_id: 'jbda-1-2' }),
20+
expect.objectContaining({ source: 'tld', external_id: 'tld-abc' }),
21+
]),
22+
);
23+
});
24+
25+
it('should be case-insensitive for source keys', () => {
26+
const input = [{ source: 'TLD', external_id: 'tld-1' }];
27+
const out = filterFeedExternalIdsToSourceMap(input);
28+
expect(out).toHaveLength(1);
29+
expect(out[0].source).toBe('TLD');
30+
});
31+
32+
it('should exclude entries missing external_id', () => {
33+
const input = [
34+
{ source: 'jbda', external_id: null },
35+
{ source: 'jbda' },
36+
{ source: 'jbda', external_id: 'jbda-5-6' },
37+
] as unknown as ExternalIdInfo;
38+
const out = filterFeedExternalIdsToSourceMap(input);
39+
expect(out).toHaveLength(1);
40+
expect(out[0].external_id).toBe('jbda-5-6');
41+
});
42+
43+
it('should return empty array when input is empty or no matches', () => {
44+
expect(filterFeedExternalIdsToSourceMap([])).toEqual([]);
45+
const input = [
46+
{ source: 'unknown', external_id: 'x' },
47+
{ source: 'also', external_id: 'y' },
48+
];
49+
expect(filterFeedExternalIdsToSourceMap(input)).toEqual([]);
50+
});
51+
52+
it('externalIdSourceMap should contain expected keys', () => {
53+
// ensure the map includes the keys we depend on
54+
expect(Object.keys(externalIdSourceMap)).toEqual(
55+
expect.arrayContaining(['jbda', 'tdg', 'ntd', 'tfs', 'tld']),
56+
);
57+
});
58+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type components } from '../services/feeds/types';
2+
3+
export type ExternalIdInfo = components['schemas']['ExternalIds'];
4+
5+
export const externalIdSourceMap: Record<
6+
string,
7+
{ label: string; translationKey: string; docsUrl?: string }
8+
> = {
9+
jbda: {
10+
label: 'JBDA',
11+
translationKey: 'externalIds.tooltips.jbda',
12+
docsUrl: 'http://docs.gtfs-data.jp/api.v2.html',
13+
},
14+
tdg: {
15+
label: 'TDG',
16+
translationKey: 'externalIds.tooltips.tdg',
17+
docsUrl:
18+
'https://doc.transport.data.gouv.fr/outils/outils-disponibles-sur-le-pan/api',
19+
},
20+
ntd: {
21+
label: 'NTD',
22+
translationKey: 'externalIds.tooltips.ntd',
23+
docsUrl:
24+
'https://www.transit.dot.gov/ntd/data-product/2023-annual-database-general-transit-feed-specification-gtfs-weblinks',
25+
},
26+
tfs: {
27+
label: 'TransitFeeds',
28+
translationKey: 'externalIds.tooltips.tfs',
29+
},
30+
tld: {
31+
label: 'Transit.land',
32+
translationKey: 'externalIds.tooltips.tld',
33+
docsUrl: 'https://www.transit.land/documentation/rest-api/feeds',
34+
},
35+
};
36+
37+
export const filterFeedExternalIdsToSourceMap = (
38+
externalIds: ExternalIdInfo,
39+
): Array<{ source: string; external_id: string }> => {
40+
return externalIds.filter(
41+
(id): id is { source: string; external_id: string } =>
42+
id?.source != null &&
43+
id?.external_id != null &&
44+
externalIdSourceMap[id.source.toLowerCase()] != undefined,
45+
);
46+
};

0 commit comments

Comments
 (0)