Support deleting objects #48
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#48
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add the ability to delete GTFS objects, starting with stops. The patch system already fully supports delete operations —
patchManager.recordDelete(), undo/redo replay, and the history UI red badge are all in place. The work is purely in wiring a delete button to the stop view UI, implementing a FK-aware confirmation modal (showing affectedstop_timeswith an option to cascade-delete them all), and navigating home after deletion. The cascade pattern is borrowed from the existingremoveExceptionflow inservice-days-controller.ts.Key tradeoff: Instead of adding mutation logic to
StopViewController(which is a view-only component), we use a callback pattern (onDeleteStop) so all DB writes stay inPageContentRenderer. This preserves the architectural boundary.Relevant Context
src/modules/patch-manager.ts—recordDelete(table, id, record)at line 194. Already full undo/redo support (inverse re-inserts frompatch.inverse.record).src/modules/history-controller.ts— already renders delete patches with redbadge-error. No changes needed.src/modules/stop-view-controller.ts—StopViewDependenciesinterface (line 16),renderStopProperties()(line 72),addEventListeners()(line 312).src/modules/page-content-renderer.ts—ContentRendererDependenciespatchManager interface (line 118),stopViewDependenciesconstruction (line 151),handleAfterRender()(line 693).src/modules/modal-utils.ts—showModal({ title, body, actions }). Non-dismissible, async, buttons disabled while pending.src/modules/navigation-actions.ts—navigateToHome()at line 15. CallsgetPageStateManager().navigateTo({ type: 'home' })directly — no callback wiring needed.stop_times.stop_id → stops.stop_idis relevant.stops.parent_stationis a self-FK (station hierarchies) — out of scope for phase 1.generateCompositeKeyFromRecord('stop_times', record)fromsrc/utils/gtfs-primary-keys.ts— already used inschedule-controller.tsandtimetable-database.ts.src/modules/service-days-controller.tslines 317–334 — the canonical pattern for delete + patch record: calldb.deleteRow()first, thenpatchManager.recordDelete().db.deleteRow: Already declared as optional inContentRendererDependencies.gtfsDatabase(line 72 ofpage-content-renderer.ts). The realGTFSDatabasepassed fromindex.tshas it — no new wiring needed.Phase 1: Extend interfaces and add delete button to stop view
Add
recordDeleteto thepatchManagertype inPageContentRenderer, addonDeleteStopcallback toStopViewDependencies, add the trash button to the stop properties header, and wire the click listener.src/modules/page-content-renderer.tsline ~130, addrecordDeleteto thepatchManagerinterface:src/modules/stop-view-controller.ts, add toStopViewDependencies:StopViewController.renderStopProperties()(line 80), change the<h2>row to a flex row with a right-aligned trash icon button: Use a simple inline SVG trash icon (heroicons style, same as used inrenderError()).StopViewController.addEventListeners()(line 312), add handler for.delete-stop-btn:PageContentRendererconstructor (line 151), passonDeleteStoptostopViewDependencies:Also updated
src/modules/browse-navigation.tsto includerecordDeletein its localpatchManagertype (needed for type compatibility). Added a stubhandleDeleteStoponPageContentRendererto be implemented in phase 2.Gotcha:
currentStopIdis set inrenderStopView()beforerenderStopProperties()is called, so it's safe to use as thedata-stop-idsource. Alternatively, passstop.stop_iddirectly — prefer that to avoid relying on instance state.Phase 2: Implement
handleDeleteStopinPageContentRendererThe core deletion flow: check for FK rows in
stop_times, show modal if any exist, cascade-delete all or just the stop, navigate home.Add import for
showModalfrom../modules/modal-utils.jsinpage-content-renderer.ts(if not already imported).Add import for
navigateToHomefrom../modules/navigation-actions.js.Add import for
generateCompositeKeyFromRecordfrom../utils/gtfs-primary-keys.js.Add
private async handleDeleteStop(stop_id: string): Promise<void>toPageContentRenderer:Gotcha:
doDeleteis called inside the modalonClickwhichshowModalawaits —navigateToHome()runs aftershowModalresolves and the modal is removed from DOM. This is fine becauseshowModalresolves afteronClickcompletes.Gotcha:
generateCompositeKeyFromRecordexpects the raw DB record object, which is whatqueryRowsreturns (shallow copies per the virtual-table invariant). This is safe.Gotcha: Each
stop_timegets its ownrecordDeletepatch entry so each is individually revertable from the Changes panel, and a full undo of the stop's delete re-inserts just the stop (not the stop_times). To undo the cascade, the user would revert each stop_time patch individually. This is intentional — batch undo is a separate future feature.Phase 3: Fix silent delete button (nothing happens on click)
The button renders but clicking it produces no console output and no action. The chain has three silent failure points that need logs added and one structural fix.
Root causes to address:
stop_idis null/emptyhandleDeleteStop— the earlyreturnon!db || !pm || !db.deleteRowis completely silentFix: switch to event delegation + add logs throughout the chain.
In
src/modules/stop-view-controller.tsaddEventListeners(), replace thequerySelectorapproach with event delegation on the container:This is robust to clicks on the SVG child element and doesn't depend on the button being in the DOM at attach time.
In
src/modules/page-content-renderer.tshandleDeleteStop(), add a log at the very top and after each early-return guard:And after the stop lookup:
Gotcha: Event delegation on the container means the listener fires for all clicks inside the browse panel —
closest('.delete-stop-btn')guards correctly so non-delete clicks are ignored immediately.Gotcha: The
patchManagerinContentRendererDependenciesmay be passed asundefinedif the feed isn't loaded yet. The new warn log will surface this immediately.Phase 4: Wire
deleteRowproxy and refresh map after deletionTwo bugs surfaced in testing: (1) the
gtfsDatabaseproxy object inbrowse-navigation.tswas missingdeleteRow, so the guard inhandleDeleteStopalways bailed early withdeleteRow: false; (2) after a successful deletion the stop marker remained visible on the map because nothing calledlayerManager.updateStopsData().src/modules/browse-navigation.tsinitializeContentRenderer(), adddeleteRowto thegtfsDatabaseproxy object, delegating tothis.gtfsRelationshipsInstance.gtfsDatabase.deleteRow.public refreshStops(): voidtoMapControllerinsrc/modules/map-controller.tsthat callsthis.layerManager?.updateStopsData().refreshStops: () => voidto themapControllerinterface inContentRendererDependenciesinsrc/modules/page-content-renderer.ts.refreshStopsto both the private field type and constructor parameter type formapControllerinsrc/modules/browse-navigation.ts.refreshStops: () => this.mapController.refreshStops()in themapControllerproxy ininitializeContentRenderer().this.dependencies.mapController.refreshStops()indoDelete()insidehandleDeleteStop, just beforenavigateToHome().Discovery:
recordDeleteinpatch-manager.tsdoes NOT callapplyPatchForward(unlikerecordUpdate). The actual DB row removal happens via the directdb.deleteRowcall beforerecordDeleteis invoked, so the virtual-table in-memory data is already updated whenrefreshStops/updateStopsDataruns —gtfsParser.getFileDataSyncTyped('stops.txt')returns the correct post-deletion list.Phase 5: Fix multiple modals on delete (accumulated event listeners)
Root cause:
addEventListenersis called on every render of the stop view. The delete handler uses event delegation on thecontainerelement, but the container persists across renders — only itsinnerHTMLis replaced. Each call toaddEventListenersstacks anotherclicklistener on the same container node, so clicking delete fires once per accumulated listener, spawning one modal per prior render.private deleteListenerAbortController: AbortController | null = nulltoStopViewController.addEventListeners, before attaching the delete click listener, abort the previous controller (this.deleteListenerAbortController?.abort()) and create a new one.{ signal: this.deleteListenerAbortController.signal }as the third argument tocontainer.addEventListener, so the previous listener is automatically removed when the controller is aborted on the next render.Why AbortController over a boolean flag: A flag would prevent re-attaching when the container changes (e.g. navigating to a different stop in the same panel);
AbortControllercorrectly removes the old listener from whatever container it was on and registers a fresh one on the current container.Original Issue
Stops should be a good place to start.
Plan: Fix cascade delete — batch patch, sticking notifications, and console warnings
Summary
Follow-on to #48. Three bugs remain after the delete-stop feature landed. All three share the same root cause: the
doDelete(cascade: true)path inhandleDeleteStopcallspm.recordDelete()separately for each stop_time and the stop, producing N+1appendAndPushcalls → N+1'change'events → N+1 notifications and N+1 asyncbrowseNavigation.refresh()calls. The fix is to batch all DB deletions, then record oneBatchGTFSPatchwith a singleappendAndPush. As a secondary fix,revertPatchinpatch-manager.tsincorrectly hardcodesop: 'update'when inverting batch patches (harmless today since batches only contain updates, but would silently break for our new delete batches). Cosmetic:renderFieldDiffsinhistory-controller.tssays "N rows updated" for all batches — should reflect the actual op type.Relevant context
PatchManager.recordBatch(src/modules/patch-manager.ts:208) — the existing batch method for update ops; our newrecordBatchDeletefollows the same pattern but for delete ops.appendAndPushis private so the new method must live onPatchManager.BatchGTFSPatch(src/types/patch.ts:22) —ops: SingleGTFSPatch[]. The type already accepts any op including'delete'— no type change needed.applyPatchInversefor batch (patch-manager.ts:124) — correctly handles any op type by dispatching onpatch.op; undo works fine. The bug is only inrevertPatch.revertPatchfor batch (patch-manager.ts:307-319) — hardcodesop: 'update' as constfor all ops when building the inverted batch. Works for update batches (the only kind that exist today), but would silently fail for delete batches (inverted payload shape{ record: ... }doesn't match theupdatebranch which expects{ changes: ... }).handleDeleteStop(src/modules/page-content-renderer.ts:813) — the cascade path runs a for-loop callingdb.deleteRow+pm.recordDeleteper stop_time, then the same for the stop itself.patchManagertype inpage-content-renderer.ts(line 122) and inbrowse-navigation.ts(line 95 +setPatchManagerat line 114) — both needrecordBatchDeleteadded.humanLabel(src/utils/patch-label.ts:7) — usespatch.labelfor batch patches; no change needed if we pass a descriptive label.renderFieldDiffsfor batch (src/modules/history-controller.ts:44) — hardcoded"rows updated"string.Why the batch approach fixes all three bugs
appendAndPushcall instead of N+1.'change'event → onenotifications.showInfocall → one notification that auto-dismisses.browseNavigation.refresh()queued. By the time the microtask queue runs it,navigateToHome()has already changed the page state to'home', sorefresh()callsrender()which renders the home view — NOTrenderStop(stop_id), sohighlightStopis never called on the deleted stop. ThegetStopTimesByStopId: index missalso disappears becausegetAgenciesServingStopis never invoked for the deleted stop.Phase 1: Add
recordBatchDeleteto PatchManager + fixrevertPatchAdd the new batch-delete recording method and fix the revert-patch inversion bug while we're in the file.
In
src/modules/patch-manager.ts, add afterrecordDelete(line 206):Note: do NOT call
applyPatchForwardhere — the DB deletions will have already happened via directdb.deleteRowcalls before this method is invoked (same contract as the existingrecordDelete).Fix
revertPatchbatch inversion (patch-manager.tsaround line 308). Replace the hardcodedop: 'update' as constwith correct inversion:Gotcha:
recordBatchDeleteintentionally skipsapplyPatchForward. The virtual table'sdeletehandler would handle a double-delete safely (early-return if row not inbyId), but skipping is cleaner and consistent withrecordDelete.Phase 2: Update
handleDeleteStop+ addrecordBatchDeleteto interface typesWire
recordBatchDeleteinto the cascade delete flow and extend the two type-narrowedpatchManagershapes.In
src/modules/page-content-renderer.tspatchManagertype (lines 122–139), add:In
src/modules/browse-navigation.ts, add the samerecordBatchDeletesignature to:setPatchManagerparameter type at line 114In
src/modules/page-content-renderer.tshandleDeleteStop, replace thedoDeleteinner function's cascade path:Current:
Replacement:
Gotcha:
pm.recordBatchDeletewith a single-elementdeleteOps(the no-cascade path) will fall through to thesinglePatches.length === 1branch inrecordBatchDeleteand record a plainSingleGTFSPatch— identical behavior to the oldpm.recordDeletecall. This is fine. You could keep the non-cascade path usingpm.recordDeletedirectly, but usingrecordBatchDeleteuniformly simplifies the logic.Gotcha: The
doDeleteclosure capturesstopTimesby reference. The stop_times are fetched before the modal is shown (if any), sostopTimesis stable. No re-query needed.Phase 3: Fix history UI batch label for non-update ops (cosmetic)
renderFieldDiffsinhistory-controller.tssays "N rows updated" for all batch patches. Update it to reflect the actual operation type.src/modules/history-controller.ts, update the'batch'branch ofrenderFieldDiffs(line 44):Gotcha: This is purely cosmetic — the patch data is correct regardless. The
opBadgeClassfunction already uses'badge-info'for all batches (no change there — mixed-op batches are fine as info color).