Support delete for more objects #110

Closed
opened 2026-05-10 23:58:39 +00:00 by maxtkc · 0 comments
Owner

Summary

Stops already support deletion (with cascade to stop_times). This feature extends the same pattern — delete button in the detail view, cascade-delete confirmation modal, atomic batch patch for undo/redo — to routes, services, agencies, and trips. Each object has a well-defined cascade chain: trips → stop_times; routes → trips → stop_times; services → trips + stop_times + calendar_dates; agencies → routes → trips → stop_times. The implementation is intentionally repetitive (no premature abstraction) to match the existing stop pattern.

Relevant Context

Existing pattern (stop deletion):

  • Delete button rendered in src/modules/stop-view-controller.ts (lines 87–91, 346–368) via .delete-stop-btn class and AbortController-based event delegation
  • Handler handleDeleteStop(stop_id) in src/modules/page-content-renderer.ts (lines 860–922): queries dependents, shows showModal() if any exist, runs db.deleteRow() calls in order (dependents first), records single pm.recordBatchDelete(ops, label), then navigates home
  • Callback onDeleteStop is wired in src/index.ts from page-content-renderer to stop-view-controller

Key utilities:

  • db.deleteRow(table, key) / db.deleteRows(table, keys)src/modules/gtfs-database.ts
  • pm.recordDelete(table, id, record) / pm.recordBatchDelete(ops, label)src/modules/patch-manager.ts
  • showModal({ title, body, actions, enterAction, escapeAction })src/modules/modal-utils.ts
  • db.queryRows(table, filter) — used to find cascade targets

View rendering locations:

  • Route detail: renderRoute() in src/modules/page-content-renderer.ts (around line 482) — no separate controller, button goes directly in the rendered HTML
  • Agency detail: src/modules/agency-view-controller.ts — uses onDeleteAgency callback injected via constructor deps
  • Service detail: src/modules/service-view-controller.ts — uses onDeleteService callback injected via constructor deps
  • Trip columns: renderTimetableHeader() in src/modules/timetable-renderer.ts (line 342) — delete button in each trip-header cell; handler lives in src/modules/schedule-controller.ts

Cascade chains:

Object Cascade
Trip stop_times (by trip_id)
Route trips (by route_id) → stop_times (by trip_id)
Service trips (by service_id) → stop_times (by trip_id), calendar_dates (by service_id)
Agency routes (by agency_id) → trips (by route_id) → stop_times (by trip_id)

Gotcha: deleteRows batches in 500-row chunks. Prefer it over looping deleteRow when deleting many stop_times. For the batch patch, flatten all delete ops into one recordBatchDelete call so undo is a single step.

Navigation after delete: navigateToHome() helper is already used in handleDeleteStop(). Use the same for route, agency, service. For trips, stay on the current timetable view by re-rendering (call whatever refresh method scheduleController uses after a trip is modified).


Phase 1: Trip deletion (from timetable)

Trips are the simplest cascade (one level: stop_times). They have no standalone detail page — deletion is triggered from the timetable column header.

  • In timetable-renderer.ts renderTimetableHeader(), add a small delete button inside each trip-header <td>, below the trip_id text: <button class="btn btn-xs btn-error btn-outline delete-trip-btn mt-1" data-trip-id="${trip_id}">Delete</button>
  • In schedule-controller.ts, add an event listener (AbortController pattern, re-added on each re-render) that delegates clicks on .delete-trip-btn to a new handleDeleteTrip(trip_id) method
  • Implement handleDeleteTrip(trip_id) in schedule-controller.ts:
    • Query stop_times by trip_id
    • Query the trip record itself (for patch inverse)
    • If no stop_times: show a simple confirmation modal, then delete + record patch + re-render timetable
    • If stop_times exist: show cascade modal with summary ("X stop_times") and offer "Delete trip + X stop_times" (btn-error) or Cancel
    • On confirm: db.deleteRows('stop_times', keys) then db.deleteRow('trips', trip_id), then pm.recordBatchDelete([...stop_time_ops, trip_op], label)
    • Re-render the timetable after deletion (not navigate home)
  • Add required deps (gtfsDatabase, patchManager) to schedule-controller if not already present — check constructor (both already present; added deleteRow/deleteRows and recordBatchDelete to local interfaces)

Gotcha: The timetable re-renders frequently; make sure the AbortController is properly reset on each render to avoid stale listeners accumulating.


Phase 2: Route deletion

Routes cascade to trips → stop_times. The route detail is rendered inline in page-content-renderer.ts, so no callback indirection is needed.

  • In page-content-renderer.ts renderRoute(), add a delete button to the route properties header area: <button class="btn btn-sm btn-error btn-outline delete-route-btn" data-route-id="${route_id}">Delete Route</button>
  • In page-content-renderer's addEventListeners() (or route-specific listener setup), wire .delete-route-btnhandleDeleteRoute(route_id)
  • Implement handleDeleteRoute(route_id) in page-content-renderer.ts:
    • Query trips by route_id to get all trips
    • For each trip, query stop_times by trip_id (or query all stop_times then filter — but per-trip is fine at this scale)
    • Build cascade summary: "X trips, Y stop_times"
    • Show modal: if no trips → simple confirm; if trips exist → "Delete route + X trips + Y stop_times" (btn-error) or Cancel
    • On confirm: delete stop_times for each trip, delete trips, delete route (all via db.deleteRows/db.deleteRow), then pm.recordBatchDelete(allOps, label), then navigateToHome()
  • Log [PageContentRenderer] Deleted route ${route_id} + ${n} trips + ${m} stop_times

Phase 3: Service deletion

Services cascade to trips → stop_times, and also to calendar_dates (same service_id).

  • In service-view-controller.ts, add an onDeleteService?: (service_id: string) => void callback to the ServiceViewControllerDeps interface (or equivalent deps type)
  • Render delete button in the service detail view header in service-view-controller.ts: <button class="btn btn-sm btn-error btn-outline delete-service-btn" data-service-id="${service_id}">Delete Service</button>
  • Wire .delete-service-btn click → dependencies.onDeleteService(service_id) in addEventListeners()
  • Implement handleDeleteService(service_id) in page-content-renderer.ts:
    • Query trips by service_id
    • For each trip, query stop_times by trip_id
    • Query calendar_dates by service_id
    • Also query the calendar record for the service_id (for the patch inverse; may not exist for calendar_dates-only services)
    • Build summary: "X trips, Y stop_times, Z calendar_dates"
    • Show modal with cascade confirm or cancel
    • On confirm: delete stop_times, trips, calendar_dates, and the calendar row (if present); record all as pm.recordBatchDelete(allOps, label); navigateToHome()
  • Wire onDeleteService in src/index.ts when constructing ServiceViewController (wired in page-content-renderer.ts constructor, not in index.ts — ServiceViewController is managed entirely inside PageContentRenderer)

Gotcha: A service may exist only in calendar_dates (no calendar row). Do not try to deleteRow('calendar', id) if the record doesn't exist — check first with db.getRow('calendar', id).

Discovery: ServiceViewController is instantiated inside PageContentRenderer's constructor (not in index.ts), so the onDeleteService callback was wired there — no index.ts change needed. The calendar_dates table uses a composite key (service_id, date) so generateCompositeKeyFromRecord handles it correctly.


Phase 4: Agency deletion

Agencies have the deepest cascade: agency → routes → trips → stop_times. Agency detail is rendered in agency-view-controller.ts.

  • Add onDeleteAgency?: (agency_id: string) => void to AgencyViewControllerDeps (or equivalent)
  • Render delete button in agency-view-controller.ts agency properties header: <button class="btn btn-sm btn-error btn-outline delete-agency-btn" data-agency-id="${agency_id}">Delete Agency</button>
  • Wire .delete-agency-btndependencies.onDeleteAgency(agency_id) in addEventListeners()
  • Implement handleDeleteAgency(agency_id) in page-content-renderer.ts:
    • Query routes by agency_id
    • For each route, query trips by route_id
    • For each trip, query stop_times by trip_id
    • Query agency record (for patch inverse)
    • Build cascade summary: "X routes, Y trips, Z stop_times"
    • Show modal with cascade confirm or cancel
    • On confirm: delete all stop_times, all trips, all routes, then the agency — record as single pm.recordBatchDelete(allOps, label); navigateToHome()
  • Wire onDeleteAgency in page-content-renderer.ts constructor (AgencyViewController is managed inside PageContentRenderer, same as ServiceViewController — no index.ts change needed)

Gotcha: Agencies without routes are a valid (if unusual) state — the simple-confirm modal path (no dependents) should still work.

Discovery: Like ServiceViewController, AgencyViewController is also instantiated inside PageContentRenderer's constructor, so onDeleteAgency was wired there — no index.ts change needed. The agency_id for the button comes from this.currentAgencyId set at render time in renderAgencyView().


Phase 5: Replace text delete buttons with trash icon

All four new delete buttons (route, service, agency, trip) still use plain text labels, while the stop delete button already uses a trash SVG icon. This phase makes them consistent by extracting the icon into a shared helper and updating all five call sites.

Shared helper location: src/modules/modal-utils.ts — already imported by all relevant modules for showModal, so adding a small HTML helper there avoids a new import in each file.

  • In src/modules/modal-utils.ts, export a renderTrashIcon(sizeClass = 'h-4 w-4') function that returns the trash SVG string (same path data as the existing stop button: M19 7l-.867 12.142...)
  • In src/modules/stop-view-controller.ts: remove the inline SVG from the delete button and replace with ${renderTrashIcon()} (import renderTrashIcon from modal-utils.ts)
  • In src/modules/page-content-renderer.ts (renderRoute()): replace Delete Route button text with ${renderTrashIcon()} (already imports from modal-utils.ts)
  • In src/modules/service-view-controller.ts: replace Delete Service button text with ${renderTrashIcon()} (add renderTrashIcon to existing modal-utils.ts import)
  • In src/modules/agency-view-controller.ts: replace Delete Agency button text with ${renderTrashIcon()} (add renderTrashIcon to existing modal-utils.ts import)
  • In src/modules/timetable-renderer.ts: replace Delete button text with ${renderTrashIcon('h-3 w-3')} to match the btn-xs button size (add renderTrashIcon to existing modal-utils.ts import)
  • Add title="Delete" attribute to each icon-only button so it has a tooltip for accessibility

Gotcha: The timetable trip header uses btn-xs — use renderTrashIcon('h-3 w-3') there; all others use btn-sm and the default h-4 w-4.


Original Issue

Right now it's only supported for stops in #48. Lets extend this to all simple objects

## Summary Stops already support deletion (with cascade to stop_times). This feature extends the same pattern — delete button in the detail view, cascade-delete confirmation modal, atomic batch patch for undo/redo — to routes, services, agencies, and trips. Each object has a well-defined cascade chain: trips → stop_times; routes → trips → stop_times; services → trips + stop_times + calendar_dates; agencies → routes → trips → stop_times. The implementation is intentionally repetitive (no premature abstraction) to match the existing stop pattern. ## Relevant Context **Existing pattern (stop deletion):** - Delete button rendered in `src/modules/stop-view-controller.ts` (lines 87–91, 346–368) via `.delete-stop-btn` class and AbortController-based event delegation - Handler `handleDeleteStop(stop_id)` in `src/modules/page-content-renderer.ts` (lines 860–922): queries dependents, shows `showModal()` if any exist, runs `db.deleteRow()` calls in order (dependents first), records single `pm.recordBatchDelete(ops, label)`, then navigates home - Callback `onDeleteStop` is wired in `src/index.ts` from page-content-renderer to stop-view-controller **Key utilities:** - `db.deleteRow(table, key)` / `db.deleteRows(table, keys)` — `src/modules/gtfs-database.ts` - `pm.recordDelete(table, id, record)` / `pm.recordBatchDelete(ops, label)` — `src/modules/patch-manager.ts` - `showModal({ title, body, actions, enterAction, escapeAction })` — `src/modules/modal-utils.ts` - `db.queryRows(table, filter)` — used to find cascade targets **View rendering locations:** - Route detail: `renderRoute()` in `src/modules/page-content-renderer.ts` (around line 482) — no separate controller, button goes directly in the rendered HTML - Agency detail: `src/modules/agency-view-controller.ts` — uses `onDeleteAgency` callback injected via constructor deps - Service detail: `src/modules/service-view-controller.ts` — uses `onDeleteService` callback injected via constructor deps - Trip columns: `renderTimetableHeader()` in `src/modules/timetable-renderer.ts` (line 342) — delete button in each trip-header cell; handler lives in `src/modules/schedule-controller.ts` **Cascade chains:** | Object | Cascade | |--------|---------| | Trip | stop_times (by `trip_id`) | | Route | trips (by `route_id`) → stop_times (by `trip_id`) | | Service | trips (by `service_id`) → stop_times (by `trip_id`), calendar_dates (by `service_id`) | | Agency | routes (by `agency_id`) → trips (by `route_id`) → stop_times (by `trip_id`) | **Gotcha:** `deleteRows` batches in 500-row chunks. Prefer it over looping `deleteRow` when deleting many stop_times. For the batch patch, flatten all delete ops into one `recordBatchDelete` call so undo is a single step. **Navigation after delete:** `navigateToHome()` helper is already used in `handleDeleteStop()`. Use the same for route, agency, service. For trips, stay on the current timetable view by re-rendering (call whatever refresh method scheduleController uses after a trip is modified). --- ## Phase 1: Trip deletion (from timetable) Trips are the simplest cascade (one level: stop_times). They have no standalone detail page — deletion is triggered from the timetable column header. - [x] In `timetable-renderer.ts` `renderTimetableHeader()`, add a small delete button inside each `trip-header` `<td>`, below the trip_id text: `<button class="btn btn-xs btn-error btn-outline delete-trip-btn mt-1" data-trip-id="${trip_id}">Delete</button>` - [x] In `schedule-controller.ts`, add an event listener (AbortController pattern, re-added on each re-render) that delegates clicks on `.delete-trip-btn` to a new `handleDeleteTrip(trip_id)` method - [x] Implement `handleDeleteTrip(trip_id)` in `schedule-controller.ts`: - Query `stop_times` by `trip_id` - Query the trip record itself (for patch inverse) - If no stop_times: show a simple confirmation modal, then delete + record patch + re-render timetable - If stop_times exist: show cascade modal with summary (`"X stop_times"`) and offer "Delete trip + X stop_times" (btn-error) or Cancel - On confirm: `db.deleteRows('stop_times', keys)` then `db.deleteRow('trips', trip_id)`, then `pm.recordBatchDelete([...stop_time_ops, trip_op], label)` - Re-render the timetable after deletion (not navigate home) - [x] Add required deps (`gtfsDatabase`, `patchManager`) to schedule-controller if not already present — check constructor (both already present; added `deleteRow`/`deleteRows` and `recordBatchDelete` to local interfaces) **Gotcha:** The timetable re-renders frequently; make sure the AbortController is properly reset on each render to avoid stale listeners accumulating. --- ## Phase 2: Route deletion Routes cascade to trips → stop_times. The route detail is rendered inline in `page-content-renderer.ts`, so no callback indirection is needed. - [x] In `page-content-renderer.ts` `renderRoute()`, add a delete button to the route properties header area: `<button class="btn btn-sm btn-error btn-outline delete-route-btn" data-route-id="${route_id}">Delete Route</button>` - [x] In page-content-renderer's `addEventListeners()` (or route-specific listener setup), wire `.delete-route-btn` → `handleDeleteRoute(route_id)` - [x] Implement `handleDeleteRoute(route_id)` in `page-content-renderer.ts`: - Query `trips` by `route_id` to get all trips - For each trip, query `stop_times` by `trip_id` (or query all stop_times then filter — but per-trip is fine at this scale) - Build cascade summary: `"X trips, Y stop_times"` - Show modal: if no trips → simple confirm; if trips exist → "Delete route + X trips + Y stop_times" (btn-error) or Cancel - On confirm: delete stop_times for each trip, delete trips, delete route (all via `db.deleteRows`/`db.deleteRow`), then `pm.recordBatchDelete(allOps, label)`, then `navigateToHome()` - [x] Log `[PageContentRenderer] Deleted route ${route_id} + ${n} trips + ${m} stop_times` --- ## Phase 3: Service deletion Services cascade to trips → stop_times, and also to calendar_dates (same service_id). - [x] In `service-view-controller.ts`, add an `onDeleteService?: (service_id: string) => void` callback to the `ServiceViewControllerDeps` interface (or equivalent deps type) - [x] Render delete button in the service detail view header in `service-view-controller.ts`: `<button class="btn btn-sm btn-error btn-outline delete-service-btn" data-service-id="${service_id}">Delete Service</button>` - [x] Wire `.delete-service-btn` click → `dependencies.onDeleteService(service_id)` in `addEventListeners()` - [x] Implement `handleDeleteService(service_id)` in `page-content-renderer.ts`: - Query `trips` by `service_id` - For each trip, query `stop_times` by `trip_id` - Query `calendar_dates` by `service_id` - Also query the `calendar` record for the service_id (for the patch inverse; may not exist for calendar_dates-only services) - Build summary: `"X trips, Y stop_times, Z calendar_dates"` - Show modal with cascade confirm or cancel - On confirm: delete stop_times, trips, calendar_dates, and the calendar row (if present); record all as `pm.recordBatchDelete(allOps, label)`; `navigateToHome()` - [x] Wire `onDeleteService` in `src/index.ts` when constructing `ServiceViewController` (wired in `page-content-renderer.ts` constructor, not in index.ts — ServiceViewController is managed entirely inside PageContentRenderer) **Gotcha:** A service may exist only in `calendar_dates` (no `calendar` row). Do not try to `deleteRow('calendar', id)` if the record doesn't exist — check first with `db.getRow('calendar', id)`. **Discovery:** `ServiceViewController` is instantiated inside `PageContentRenderer`'s constructor (not in `index.ts`), so the `onDeleteService` callback was wired there — no `index.ts` change needed. The `calendar_dates` table uses a composite key `(service_id, date)` so `generateCompositeKeyFromRecord` handles it correctly. --- ## Phase 4: Agency deletion Agencies have the deepest cascade: agency → routes → trips → stop_times. Agency detail is rendered in `agency-view-controller.ts`. - [x] Add `onDeleteAgency?: (agency_id: string) => void` to `AgencyViewControllerDeps` (or equivalent) - [x] Render delete button in `agency-view-controller.ts` agency properties header: `<button class="btn btn-sm btn-error btn-outline delete-agency-btn" data-agency-id="${agency_id}">Delete Agency</button>` - [x] Wire `.delete-agency-btn` → `dependencies.onDeleteAgency(agency_id)` in `addEventListeners()` - [x] Implement `handleDeleteAgency(agency_id)` in `page-content-renderer.ts`: - Query `routes` by `agency_id` - For each route, query `trips` by `route_id` - For each trip, query `stop_times` by `trip_id` - Query agency record (for patch inverse) - Build cascade summary: `"X routes, Y trips, Z stop_times"` - Show modal with cascade confirm or cancel - On confirm: delete all stop_times, all trips, all routes, then the agency — record as single `pm.recordBatchDelete(allOps, label)`; `navigateToHome()` - [x] Wire `onDeleteAgency` in `page-content-renderer.ts` constructor (AgencyViewController is managed inside PageContentRenderer, same as ServiceViewController — no index.ts change needed) **Gotcha:** Agencies without routes are a valid (if unusual) state — the simple-confirm modal path (no dependents) should still work. **Discovery:** Like ServiceViewController, `AgencyViewController` is also instantiated inside `PageContentRenderer`'s constructor, so `onDeleteAgency` was wired there — no `index.ts` change needed. The `agency_id` for the button comes from `this.currentAgencyId` set at render time in `renderAgencyView()`. --- ## Phase 5: Replace text delete buttons with trash icon All four new delete buttons (route, service, agency, trip) still use plain text labels, while the stop delete button already uses a trash SVG icon. This phase makes them consistent by extracting the icon into a shared helper and updating all five call sites. **Shared helper location:** `src/modules/modal-utils.ts` — already imported by all relevant modules for `showModal`, so adding a small HTML helper there avoids a new import in each file. - [x] In `src/modules/modal-utils.ts`, export a `renderTrashIcon(sizeClass = 'h-4 w-4')` function that returns the trash SVG string (same path data as the existing stop button: `M19 7l-.867 12.142...`) - [x] In `src/modules/stop-view-controller.ts`: remove the inline SVG from the delete button and replace with `${renderTrashIcon()}` (import `renderTrashIcon` from `modal-utils.ts`) - [x] In `src/modules/page-content-renderer.ts` (`renderRoute()`): replace `Delete Route` button text with `${renderTrashIcon()}` (already imports from `modal-utils.ts`) - [x] In `src/modules/service-view-controller.ts`: replace `Delete Service` button text with `${renderTrashIcon()}` (add `renderTrashIcon` to existing `modal-utils.ts` import) - [x] In `src/modules/agency-view-controller.ts`: replace `Delete Agency` button text with `${renderTrashIcon()}` (add `renderTrashIcon` to existing `modal-utils.ts` import) - [x] In `src/modules/timetable-renderer.ts`: replace `Delete` button text with `${renderTrashIcon('h-3 w-3')}` to match the `btn-xs` button size (add `renderTrashIcon` to existing `modal-utils.ts` import) - [x] Add `title="Delete"` attribute to each icon-only button so it has a tooltip for accessibility **Gotcha:** The timetable trip header uses `btn-xs` — use `renderTrashIcon('h-3 w-3')` there; all others use `btn-sm` and the default `h-4 w-4`. --- ## Original Issue Right now it's only supported for stops in #48. Lets extend this to all simple objects
maxtkc self-assigned this 2026-05-10 23:58:39 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book#110
No description provided.