Context
Today, when the side editor is open on the /vehicles route, the table's sort column headers are locked — clicks are suppressed and headers dimmed (DataTableHeader.tsx:59-70), driven by sortLocked={!!editingItem} set in GenericDataViewPage.tsx:194. The lock is binary on editingItem and is not dirty-aware. Pagination, page-size, and search/filter inputs are not locked at all today.
This UX is too coarse: it punishes the common case (user wants to keep browsing while a non-modified row is "selected") and gives no path out beyond an explicit close. The replacement flow is:
- Sort header click or page switch → collapse the open details panel first, then perform the action.
- Unless the editor is dirty → open a confirm dialog warning the user about unsaved changes and asking them to confirm discard. On confirm: collapse + proceed. On cancel: stay where we are.
A DiscardDialog component already exists (src/components/dialogs/DiscardDialog.tsx) and is used by EditorRail.tsx:193-198 for the collapse/cancel flows — reuse it directly.
Dirty tracking already exists for the vehicle editor (src/data/vehicles/vehicleFormState.ts:44-46 exposes isDirty(), consumed via useDirtyFormBlock(isDirty) at VehicleDetails.tsx:65). It is not currently surfaced to GenericDataViewPage — needs a small lift.
Scope
GenericDataViewPage-using routes only. Today that is just /vehicles (/vehicle-types and /deck-plans have bespoke editors and are not affected). Trigger surface: sort header click and pagination change (next / prev / page-size). Search/filter inputs are out of scope.
Plan
1. Remove the legacy sort lock
src/pages/GenericDataViewPage.tsx:194 — drop the sortLocked={!!editingItem} prop.
src/components/data/DataPageContent.tsx:43, 66, 121 — drop the sortLocked plumbing.
src/components/data/DataTableHeader.tsx:59-70 — remove the sortLocked branch, opacity dimming, and disabled tooltip. Headers click normally again.
- Translation keys related to the locked tooltip (if any) — clean up.
2. Lift dirty state to EditingContext
Extend src/contexts/EditingContext.tsx so the active editor can report its dirty status up:
```ts
interface EditingContextType {
editingItem: EditingItem | null;
setEditingItem: (item: EditingItem | null) => void;
isEditorDirty: boolean;
setEditorDirty: (dirty: boolean) => void;
}
```
Inside VehicleDetails.tsx, replace the local useDirtyFormBlock(isDirty) wiring with an effect that pushes isDirty() into the context whenever the form state changes. Reset to false on unmount.
3. Add a guarded-close helper
Create a small hook, e.g. src/pages/useGuardedEditorClose.ts, that returns a function closeOrConfirm(onConfirm: () => void):
- If no
editingItem → call onConfirm() immediately.
- If
editingItem && !isEditorDirty → setEditingItem(null), then onConfirm().
- If dirty → open
DiscardDialog. On discard: clear editor, run onConfirm(). On cancel: no-op.
The dialog open state lives inside the hook (returns { closeOrConfirm, dialog } where dialog is the JSX to render).
4. Wire sort + pagination through the guard
In GenericDataViewPage.tsx:
- Wrap the existing
onRequestSort handler in closeOrConfirm.
- Wrap pagination
onPageChange and page-size change in closeOrConfirm.
- Render the dialog JSX returned by the hook somewhere stable in the page tree.
For the vehicles route specifically, sort and pagination flow through vehicleViewConfig.tsx handlers → useVehicles hook. The wrap lives at the GenericDataViewPage boundary so all consumers of the generic page inherit the behaviour for free.
5. URL-deep-link interaction
The ?selected=<id> URL param drives editor open via useVehicleUrlSelection.tsx. When the guard closes the editor, the URL must be cleared too — reuse the existing clear path at useVehicleUrlSelection.tsx:55. Confirm no race: clearing the URL before sort fires should not retrigger reselection.
Critical files
| File |
Change |
src/pages/GenericDataViewPage.tsx |
Drop sortLocked; wire sort + pagination through guard; render dialog |
src/components/data/DataPageContent.tsx |
Drop sortLocked plumbing |
src/components/data/DataTableHeader.tsx |
Remove lock UI |
src/contexts/EditingContext.tsx |
Add isEditorDirty + setter |
src/data/vehicles/VehicleDetails.tsx |
Report dirty into context |
src/data/vehicles/vehicleFormState.ts |
(reuse existing isDirty) |
src/pages/useGuardedEditorClose.ts |
NEW — guarded close hook |
src/components/dialogs/DiscardDialog.tsx |
(reuse) |
Verification
/vehicles with editor open + clean form: clicking a sort header collapses the panel and re-sorts the table; pagination next/prev/page-size all collapse the panel and apply.
/vehicles with editor open + dirty form: same actions open DiscardDialog. Cancel → editor stays open, sort/page action discarded. Discard → editor closes, action applies.
- URL
?selected=<id> is cleared whenever the editor collapses via the guard.
- Deep-link
/vehicles?selected=<id> still opens the editor as before.
- E2E coverage: add a Playwright test under
e2e-tests/no-auth/ exercising sort + pagination with editor open, both clean and dirty branches.
Context
Today, when the side editor is open on the
/vehiclesroute, the table's sort column headers are locked — clicks are suppressed and headers dimmed (DataTableHeader.tsx:59-70), driven bysortLocked={!!editingItem}set inGenericDataViewPage.tsx:194. The lock is binary oneditingItemand is not dirty-aware. Pagination, page-size, and search/filter inputs are not locked at all today.This UX is too coarse: it punishes the common case (user wants to keep browsing while a non-modified row is "selected") and gives no path out beyond an explicit close. The replacement flow is:
A
DiscardDialogcomponent already exists (src/components/dialogs/DiscardDialog.tsx) and is used byEditorRail.tsx:193-198for the collapse/cancel flows — reuse it directly.Dirty tracking already exists for the vehicle editor (
src/data/vehicles/vehicleFormState.ts:44-46exposesisDirty(), consumed viauseDirtyFormBlock(isDirty)atVehicleDetails.tsx:65). It is not currently surfaced toGenericDataViewPage— needs a small lift.Scope
GenericDataViewPage-using routes only. Today that is just/vehicles(/vehicle-typesand/deck-planshave bespoke editors and are not affected). Trigger surface: sort header click and pagination change (next / prev / page-size). Search/filter inputs are out of scope.Plan
1. Remove the legacy sort lock
src/pages/GenericDataViewPage.tsx:194— drop thesortLocked={!!editingItem}prop.src/components/data/DataPageContent.tsx:43, 66, 121— drop thesortLockedplumbing.src/components/data/DataTableHeader.tsx:59-70— remove thesortLockedbranch, opacity dimming, and disabled tooltip. Headers click normally again.2. Lift dirty state to
EditingContextExtend
src/contexts/EditingContext.tsxso the active editor can report its dirty status up:```ts
interface EditingContextType {
editingItem: EditingItem | null;
setEditingItem: (item: EditingItem | null) => void;
isEditorDirty: boolean;
setEditorDirty: (dirty: boolean) => void;
}
```
Inside
VehicleDetails.tsx, replace the localuseDirtyFormBlock(isDirty)wiring with an effect that pushesisDirty()into the context whenever the form state changes. Reset tofalseon unmount.3. Add a guarded-close helper
Create a small hook, e.g.
src/pages/useGuardedEditorClose.ts, that returns a functioncloseOrConfirm(onConfirm: () => void):editingItem→ callonConfirm()immediately.editingItem && !isEditorDirty→setEditingItem(null), thenonConfirm().DiscardDialog. On discard: clear editor, runonConfirm(). On cancel: no-op.The dialog open state lives inside the hook (returns
{ closeOrConfirm, dialog }wheredialogis the JSX to render).4. Wire sort + pagination through the guard
In
GenericDataViewPage.tsx:onRequestSorthandler incloseOrConfirm.onPageChangeand page-size change incloseOrConfirm.For the vehicles route specifically, sort and pagination flow through
vehicleViewConfig.tsxhandlers →useVehicleshook. The wrap lives at theGenericDataViewPageboundary so all consumers of the generic page inherit the behaviour for free.5. URL-deep-link interaction
The
?selected=<id>URL param drives editor open viauseVehicleUrlSelection.tsx. When the guard closes the editor, the URL must be cleared too — reuse the existing clear path atuseVehicleUrlSelection.tsx:55. Confirm no race: clearing the URL before sort fires should not retrigger reselection.Critical files
src/pages/GenericDataViewPage.tsxsortLocked; wire sort + pagination through guard; render dialogsrc/components/data/DataPageContent.tsxsortLockedplumbingsrc/components/data/DataTableHeader.tsxsrc/contexts/EditingContext.tsxisEditorDirty+ settersrc/data/vehicles/VehicleDetails.tsxsrc/data/vehicles/vehicleFormState.tsisDirty)src/pages/useGuardedEditorClose.tssrc/components/dialogs/DiscardDialog.tsxVerification
/vehicleswith editor open + clean form: clicking a sort header collapses the panel and re-sorts the table; pagination next/prev/page-size all collapse the panel and apply./vehicleswith editor open + dirty form: same actions openDiscardDialog. Cancel → editor stays open, sort/page action discarded. Discard → editor closes, action applies.?selected=<id>is cleared whenever the editor collapses via the guard./vehicles?selected=<id>still opens the editor as before.e2e-tests/no-auth/exercising sort + pagination with editor open, both clean and dirty branches.