Handle PK change in table view #14
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#14
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?
Right now it fails. It shouldn't be too hard to make this succeed
Plan: PK Display, Locking, and Stop Creation Modal
Objectives
Display PKs prominently everywhere — Users need to see both the human-readable name and the technical ID together. Two stops may have identical names but different
stop_idvalues; the ID is the distinguishing piece of information and must never be hidden.Lock PKs after creation everywhere — Primary key fields are immutable once a record exists. The raw CSV file editor currently has no awareness of PKs and allows editing them, which can silently corrupt the feed. All views must treat PKs as read-only consistently.
Give users control over PKs at creation time — The only auto-generated PK in the app is for stops (map click →
stop_${Date.now()}). Users must be able to set a meaningful ID before the record is committed. All other entities (routes, trips, services, agencies) already ask for a user-provided ID at creation and need no changes.Consistent abstraction — Every entity type should have a single function that returns its display information (
{ primary, secondary }), and two shared rendering helpers handle card/list context vs. dropdown/inline context. No ad-hoc name fallback logic scattered across modules.Key Research Notes
src/utils/gtfs-primary-keys.ts— already contains the definitiveGTFS_PRIMARY_KEYSarray andgetGTFSPrimaryKey(tableName)helper. Use this as the source of truth for PK field names in the CSV editor locking phase.src/modules/modal-utils.ts—showModal({ title, body, actions })is the existing modal abstraction. Thebodyis an HTML string injected into a<p class="py-4">wrapper. For Phase 3 we need form inputs in the body, so the wrapper must be changed to<div class="py-4">(a<p>cannot contain block/form elements). This is the only change needed to the modal utility itself.src/modules/interaction-handler.ts—handleAddStopClickcurrently generatesstop_${Date.now()}and inserts the stop immediately. This is the only place that needs a creation modal.src/modules/editor.ts— the CSV table editor renders all columns as identical<input type="text">elements with no PK awareness. Fix in Phase 2.src/utils/field-component.ts— form views already render PKs as<input disabled>with a lock icon. No changes needed there.crypto.randomUUID()— available natively in all target browsers; no library needed. Use for the suggested stop ID in the creation modal.No
route-view-controller.tsexists — route detail rendering lives insidepage-content-renderer.tsandagency-view-controller.ts. Check both during Phase 1.How to Keep This Plan Updated
After completing each checklist item, mark it
[x]. After completing a full phase, add✅ DONEto the phase heading. If a file path turns out to be wrong, correct it inline.Phase 1 — Entity Display Abstraction ✅ DONE
Create the
EntityDisplayInfotype and per-entity getter functions in a new utility file. Then create the two rendering helpers used by all phases.1.1 — Create
src/utils/entity-display.tsEntityDisplayInfotype:Rule for
secondary: only populate it when there is a meaningful human-readableprimarythat is distinct from the PK. If the primary IS the PK (no name field), leavesecondaryundefined to avoid repeating it.Per-entity getters (inputs are plain record objects, not typed GTFS types, for flexibility):
primarysecondaryagency_name || agency_idagency_idif name existsstop_name || stop_idstop_idif name existsroute_short_name || route_long_name || route_idroute_idif name existstrip_headsign || trip_short_name || trip_idtrip_idif name existsservice_idundefinedshape_idundefinedlevel_name || level_idlevel_idif name existspathway_idundefinedfare_idundefinednetwork_idundefinedarea_idundefinedTwo rendering helpers:
Checklist:
src/utils/entity-display.tswithEntityDisplayInfotypegetAgencyDisplay,getStopDisplay,getRouteDisplay,getTripDisplaygetServiceDisplay,getShapeDisplay,getLevelDisplay,getPathwayDisplay,getFareAttributeDisplay,getNetworkDisplay,getAreaDisplayrenderCardLabel(info)renderOptionLabel(info)npm run typecheckto confirm no issuesPhase 2 — Apply Display Labels Everywhere ✅ DONE
Update every place in the UI that currently shows a name without an ID (or an ID without a name). Use the getters and helpers from Phase 1.
2.1 — Home page (
src/modules/page-content-renderer.ts)Currently:
agency_name || agency_id(name OR id, never both).getAgencyDisplay+renderCardLabelgetServiceDisplay+renderCardLabelservice_idis shown — no change in visual output, but now goes through the consistent abstraction2.2 — Agency view (
src/modules/agency-view-controller.ts)Currently:
route_short_name || route_idfor route cards.getRouteDisplay+renderCardLabelso both short name androute_idappear when a short name exists2.3 — Timetable (
src/modules/timetable-renderer.ts)trip_idas cell text with headsign as tooltip. Change togetTripDisplay+renderCardLabelso headsign (if present) is primary andtrip_idis the subtext. Keep the existing title attribute for full hover.stop_name || stop_id. Change togetStopDisplay+renderOptionLabelfor the<option>text (stop-change dropdown).stop_name || stop_id. Change togetStopDisplay+renderOptionLabelfor the<option>text.2.4 — Stop detail panel (
src/modules/stop-view-controller.ts)The panel header currently shows stop name. The PK is shown only in the disabled form field below. Make the ID visible in the panel header itself.
getStopDisplay+renderCardLabelsostop_idappears as subtext directly under the stop name in the header.2.5 — Route/trip detail headers
Identify where route and trip detail headers are rendered (likely in
page-content-renderer.tsor inline in controller HTML strings) and applygetRouteDisplay/getTripDisplay+renderCardLabel.renderRouteinpage-content-renderer.ts)2.6 — Service dropdowns
Any
<select>that lists services (e.g., the service filter in the timetable/schedule view) currently shows onlyservice_id. ApplygetServiceDisplay+renderOptionLabel.<select>elements that populate with services and update them (route view service selector inpage-content-renderer.ts)Phase 3 — Lock PKs in CSV File Editor ✅ DONE
The raw CSV editor in
src/modules/editor.tsrenders every cell as an editable<input>. PK-component fields must be rendered as read-only.3.1 — Detect PK columns
When the editor opens a file, resolve the table name from the filename (strip
.txt), then callgetGTFSPrimaryKey(tableName)fromsrc/utils/gtfs-primary-keys.ts. Store the resultingfieldsarray (may be empty forall_fieldstables).getGTFSPrimaryKeyineditor.tspkFields: Set<string>from the PK configall_fieldstype tables, treat every column as a PK (lock all cells)nonetype tables (feed_info), lock nothing (single row, no identity concept)3.2 — Render PK cells as read-only
For each cell, if
pkFields.has(header), render a read-only cell instead of an input:The cell should be visually distinct (muted background) but the value should be selectable and copyable. Do not use
<input disabled>here — disabled inputs are not selectable in all browsers.editor.tsstop_times,shapes,calendar_dates,frequencies,fare_products,fare_leg_rules,translations) all lock the correct fieldsall_fieldstables (transfers,fare_rules,fare_transfer_rules,stop_areas,route_networks,attributions, etc.) lock every column3.3 — Ensure change events skip PK cells
The editor's change/input event listener currently fires for all cells. Confirm it cannot receive events from the new read-only cells (they are
<span>, not<input>, so no change events will fire — verify this is sufficient).editor.tstargetsinputelements, nottdelementsPhase 4 — Stop Creation Modal ✅ DONE (pending smoke test)
Replace the auto-generate-and-insert flow with a modal that collects the stop ID before inserting.
4.1 — Extend
modal-utils.tsTwo minimal changes needed:
a) Body wrapper: change
<p class="py-4">→<div class="py-4">so form inputs inside the body are valid HTML (a<p>cannot contain block/form elements).b) Keep-open support: change the
onClickreturn type fromvoid | Promise<void>toboolean | void | Promise<boolean | void>. If the callback returnstrue, the modal stays open and buttons are re-enabled. Any other return value (includingundefined) closes the modal as before. This lets validation failures keep the modal alive without restructuring the abstraction.In the event listener, after
await options.actions[idx].onClick(), check the return value:c)
onMounthook: addonMount?: () => voidto the options, called immediately afterdocument.body.appendChild(modal). Used for auto-focusing the stop ID input.src/modules/modal-utils.ts:<p class="py-4">→<div class="py-4">ModalAction.onClickreturn type toboolean | void | Promise<boolean | void>onMount?: () => voidto the options interface and call it afterappendChild4.2 — Add UUID suggestion helper
In
src/modules/interaction-handler.ts(or a small inline function), generate the suggested stop ID usingcrypto.randomUUID(). This gives a universally unique value the user can accept or replace.generateSuggestedStopId(): stringthat returnscrypto.randomUUID()4.3 — Replace auto-insert with modal flow
Rewrite
handleAddStopClickinsrc/modules/interaction-handler.ts:New flow:
{ lng, lat }from the click event.suggestedId = crypto.randomUUID().showModalwith:"New Stop"[{ label: "Cancel", onClick: () => {} }, { label: "Create Stop", className: "btn-primary", onClick: createStop }]createStopaction callback: a. Read(document.getElementById('new-stop-id-input') as HTMLInputElement).value.trim()b. If empty: show an inline error message in the modal body (e.g., set a<p id="stop-id-error">element's text to"Stop ID is required") and returntrueto keep the modal open. c. If the ID already exists (check againstgtfsParser.getFileDataSync('stops.txt')): show the same inline error"Stop ID already exists"and returntrue. d. Otherwise: build and insert theGTFS.Stoprecord as before, callthis.callbacks.onStopCreated(stopId), and returnundefinedto close the modal.undefined(modal closes, nothing inserted). Additionally, exit "add stop" mode (callthis.callbacks.onCancelAddStop()or equivalent so the map cursor resets and the toolbar reflects that we are no longer in add-stop mode).this.callbacks.onStopCreated(stopId), ensure the UI navigates to / focuses the newly created stop (e.g., open the stop detail panel for that stop).The inline error element should be present in the body HTML from the start (empty text, hidden via
class="hidden"), and toggled visible on failure. This avoids layout shift from injecting new elements.handleAddStopClickto defer insertion until modal confirmationcreateStopcallback that reads input, validates, and insertsstop_${Date.now()}generation logic entirely4.4 — Auto-focus the input
Use the
onMounthook added in 4.1 to focus and select the stop ID input immediately after the modal appears, so the user can type a replacement ID without manually clicking or clearing the UUID.handleAddStopClick, passonMount: () => { (document.getElementById('new-stop-id-input') as HTMLInputElement).focus(); (document.getElementById('new-stop-id-input') as HTMLInputElement).select(); }Phase 5 — QA and Cleanup
npm run typecheck— fix all type errorsnpm run lint— fix all lint warningsnpm test— confirm existing tests passstops.txtin Files view — confirmstop_idcolumn is visually lockedstop_times.txtin Files view — confirmtrip_idandstop_sequenceare lockedtransfers.txt— confirm all columns are lockedfeed_info.txt— confirm no columns are locked