Skip to content

Commit 6bff4a3

Browse files
committed
feat: GTFS-feed import dialog on the data workflows page
The previous Manage Feeds button linked to /admin/integrations/transit-gtfs-local, where the only configurable thing in the integration manifest is a boolean enabled flag — there was nowhere to actually paste a URL. Add an inline import dialog right on the Data Workflows page (URL + optional slug + optional display name) that POSTs to /api/gtfs/feeds and reuses the existing list/refresh wiring. First step toward unifying the MOTIS and Postgres GTFS sources; follow-up will surface MOTIS-fetched feeds in the same table and let the operator promote one into Postgres without re-typing a URL.
1 parent dd732c8 commit 6bff4a3

1 file changed

Lines changed: 93 additions & 7 deletions

File tree

apps/web/src/components/admin/services/DataWorkflowsPage.tsx

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,10 @@ function BuildsSection({ builds }: { builds: BuildStatus[] }) {
600600
function GtfsSection({ feeds, apiUrl }: { feeds: GtfsFeed[]; apiUrl: string }) {
601601
const queryClient = useQueryClient();
602602
const [toast, setToast] = useState<string | null>(null);
603+
const [importOpen, setImportOpen] = useState(false);
604+
const [importUrl, setImportUrl] = useState("");
605+
const [importSlug, setImportSlug] = useState("");
606+
const [importName, setImportName] = useState("");
603607

604608
const removeMutation = useMutation({
605609
mutationFn: async (slug: string) => {
@@ -616,6 +620,36 @@ function GtfsSection({ feeds, apiUrl }: { feeds: GtfsFeed[]; apiUrl: string }) {
616620
onError: () => setToast("Failed to remove feed."),
617621
});
618622

623+
const importMutation = useMutation({
624+
mutationFn: async (input: { url: string; slug?: string; name?: string }) => {
625+
const res = await fetch(`${apiUrl}/api/gtfs/feeds`, {
626+
method: "POST",
627+
credentials: "include",
628+
headers: { "Content-Type": "application/json" },
629+
body: JSON.stringify({
630+
url: input.url.trim(),
631+
slug: input.slug?.trim() || undefined,
632+
name: input.name?.trim() || undefined,
633+
}),
634+
});
635+
const body = (await res.json().catch(() => ({}))) as {
636+
slug?: string;
637+
error?: string;
638+
};
639+
if (!res.ok) throw new Error(body.error ?? `Import failed (HTTP ${res.status})`);
640+
return body.slug;
641+
},
642+
onSuccess: (slug) => {
643+
setToast(`Import started for "${slug}". Watch the table for progress.`);
644+
setImportOpen(false);
645+
setImportUrl("");
646+
setImportSlug("");
647+
setImportName("");
648+
void queryClient.invalidateQueries({ queryKey: ["admin-services-data"] });
649+
},
650+
onError: (err) => setToast((err as Error).message),
651+
});
652+
619653
return (
620654
<Paper variant="outlined" sx={{ p: 2.5 }}>
621655
<Stack direction="row" alignItems="center" spacing={1} mb={2}>
@@ -624,16 +658,68 @@ function GtfsSection({ feeds, apiUrl }: { feeds: GtfsFeed[]; apiUrl: string }) {
624658
GTFS Feeds
625659
</Typography>
626660
<Box sx={{ flex: 1 }} />
627-
<Button
628-
component={Link}
629-
href="/admin/integrations/transit-gtfs-local"
630-
variant="outlined"
631-
size="small"
632-
>
633-
Manage Feeds
661+
<Button variant="contained" size="small" onClick={() => setImportOpen(true)}>
662+
Import feed
634663
</Button>
635664
</Stack>
636665

666+
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="sm">
667+
<DialogTitle>Import GTFS feed</DialogTitle>
668+
<DialogContent>
669+
<Stack spacing={2} sx={{ mt: 0.5 }}>
670+
<Typography variant="body2" color="text.secondary">
671+
Paste the URL of a GTFS .zip. The importer will stream the archive, parse the CSVs,
672+
and load them into a dedicated `gtfs_&lt;slug&gt;` Postgres schema. Slug is
673+
auto-derived from the URL filename if you leave it blank.
674+
</Typography>
675+
<TextField
676+
label="GTFS zip URL"
677+
placeholder="https://example.com/feed.gtfs.zip"
678+
fullWidth
679+
size="small"
680+
value={importUrl}
681+
onChange={(e) => setImportUrl(e.target.value)}
682+
autoFocus
683+
/>
684+
<TextField
685+
label="Slug (optional)"
686+
placeholder="vbb"
687+
helperText="Used as the Postgres schema name (gtfs_<slug>) and the gtfs-local provider prefix (g-<slug>:). Lowercase letters, digits, hyphens, underscores."
688+
fullWidth
689+
size="small"
690+
value={importSlug}
691+
onChange={(e) => setImportSlug(e.target.value)}
692+
/>
693+
<TextField
694+
label="Display name (optional)"
695+
placeholder="VBB Berlin-Brandenburg"
696+
fullWidth
697+
size="small"
698+
value={importName}
699+
onChange={(e) => setImportName(e.target.value)}
700+
/>
701+
</Stack>
702+
</DialogContent>
703+
<DialogActions>
704+
<Button onClick={() => setImportOpen(false)} disabled={importMutation.isPending}>
705+
Cancel
706+
</Button>
707+
<Button
708+
variant="contained"
709+
disabled={!importUrl.trim() || importMutation.isPending}
710+
onClick={() =>
711+
importMutation.mutate({
712+
url: importUrl,
713+
slug: importSlug,
714+
name: importName,
715+
})
716+
}
717+
>
718+
{importMutation.isPending ? "Starting…" : "Start import"}
719+
</Button>
720+
</DialogActions>
721+
</Dialog>
722+
637723
{feeds.length === 0 ? (
638724
<Alert severity="info">No GTFS feeds imported yet.</Alert>
639725
) : (

0 commit comments

Comments
 (0)