Enhance PlanningLayout and TrainingUnitEditPage with unsaved changes handling
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s
Test Suite / pytest-backend (pull_request) Successful in 36s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 34s
Test Suite / playwright-tests (pull_request) Successful in 1m13s

- Updated PlanningLayout to conditionally render the PlanningRouteNav based on the current path, improving navigation for planning unit editors.
- Enhanced TrainingUnitEditPage with unsaved changes detection, integrating a prompt for users to confirm before leaving the page with unsaved changes.
- Introduced utility functions for creating a stable snapshot of form data to facilitate dirty-checking, ensuring better user experience during form editing.
- Added tests for the new utility functions to validate their behavior in various scenarios.
This commit is contained in:
Lars 2026-05-19 14:39:46 +02:00
parent 472cf1afdb
commit cb868373f4
6 changed files with 101 additions and 3 deletions

View File

@ -1,11 +1,15 @@
import { Outlet } from 'react-router-dom'
import { Outlet, useLocation } from 'react-router-dom'
import PlanningRouteNav from '../components/planning/PlanningRouteNav'
import { isPlanningUnitEditorPath } from '../utils/planningUnitRoutes'
/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */
export default function PlanningLayout() {
const { pathname } = useLocation()
const hideRouteNav = isPlanningUnitEditorPath(pathname)
return (
<div className="app-page planning-layout">
<PlanningRouteNav />
{!hideRouteNav ? <PlanningRouteNav /> : null}
<div className="planning-layout__main">
<Outlet />
</div>

View File

@ -26,9 +26,12 @@ import {
createEmptyTrainingUnitFormData,
buildTrainingUnitSavePayload,
trainingUnitToFormFields,
trainingUnitFormSnapshot,
validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
export default function TrainingUnitEditPage() {
@ -54,6 +57,45 @@ export default function TrainingUnitEditPage() {
const formRef = useRef(formData)
formRef.current = formData
const baselineRef = useRef(null)
const [baselineReady, setBaselineReady] = useState(false)
const [bypassDirty, setBypassDirty] = useState(false)
const dirtySignature = trainingUnitFormSnapshot(formRef.current, {
editingUnit,
draftPlanTemplateId,
})
useEffect(() => {
baselineRef.current = null
setBaselineReady(false)
setBypassDirty(false)
}, [isNew, unitId])
useEffect(() => {
if (loading) return undefined
const handle = window.setTimeout(() => {
baselineRef.current = trainingUnitFormSnapshot(formRef.current, {
editingUnit,
draftPlanTemplateId,
})
setBaselineReady(true)
}, 120)
return () => clearTimeout(handle)
// Baseline nur nach initialem Laden nicht bei Template-/Form-Änderungen im Editor
// eslint-disable-next-line react-hooks/exhaustive-deps -- editingUnit/draftPlanTemplateId bewusst ausgeschlossen
}, [loading, isNew, unitId])
const formDirtyEffective =
baselineReady &&
baselineRef.current != null &&
!bypassDirty &&
!loading &&
dirtySignature !== baselineRef.current
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
@ -400,10 +442,18 @@ export default function TrainingUnitEditPage() {
const reloadUnitAfterSave = useCallback(
async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId)
const nextDraftTemplateId = fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : ''
setEditingUnit(fullUnit)
setDraftPlanTemplateId(nextDraftTemplateId)
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
setFormData(trainingUnitToFormFields(fullUnit, sections))
const nextForm = trainingUnitToFormFields(fullUnit, sections)
setFormData(nextForm)
baselineRef.current = trainingUnitFormSnapshot(nextForm, {
editingUnit: fullUnit,
draftPlanTemplateId: nextDraftTemplateId,
})
setBypassDirty(false)
if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
}
@ -448,6 +498,11 @@ export default function TrainingUnitEditPage() {
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
)
const handleUnsavedDialogSave = async () => {
const ok = await handleSubmit(null, { closeAfter: false })
if (ok) blocker.proceed()
}
const actionConfig = useMemo(
() => ({
formId: 'planning-unit-form',
@ -726,6 +781,14 @@ export default function TrainingUnitEditPage() {
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
/>
</PageFormEditorChrome>
)
}

View File

@ -64,6 +64,13 @@ export function buildPlanningHubReturnState(hubState = {}) {
}
}
/** @param {string} pathname */
export function isPlanningUnitEditorPath(pathname) {
if (!pathname) return false
if (pathname === '/planning/units/new') return true
return /^\/planning\/units\/\d+\/edit$/.test(pathname)
}
/** @param {ReturnType<typeof buildPlanningHubReturnState>|null|undefined} state */
export function planningHubPathFromReturnState(state) {
if (!state || state.v !== 1) return PLANNING_HUB_PATH

View File

@ -3,6 +3,7 @@ import {
buildPlanUnitEditPath,
buildPlanUnitNewPath,
buildPlanningHubReturnState,
isPlanningUnitEditorPath,
legacyPlanningUnitDeepLinkTarget,
parsePlanningHubQuery,
planningHubPathFromReturnState,
@ -22,6 +23,13 @@ describe('planningUnitRoutes', () => {
expect(buildPlanUnitNewPath()).toBe('/planning/units/new')
})
it('isPlanningUnitEditorPath', () => {
expect(isPlanningUnitEditorPath('/planning/units/new')).toBe(true)
expect(isPlanningUnitEditorPath('/planning/units/42/edit')).toBe(true)
expect(isPlanningUnitEditorPath('/planning')).toBe(false)
expect(isPlanningUnitEditorPath('/planning/training-modules')).toBe(false)
})
it('legacyPlanningUnitDeepLinkTarget', () => {
expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit')
expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe(

View File

@ -131,3 +131,11 @@ export function validateTrainingUnitFormForSave(formData) {
}
return { ok: true }
}
/** Stabiler Fingerabdruck für Dirty-Check (identisch zum Save-Payload). */
export function trainingUnitFormSnapshot(
formData,
{ editingUnit = null, draftPlanTemplateId = '' } = {}
) {
return JSON.stringify(buildTrainingUnitSavePayload(formData, { editingUnit, draftPlanTemplateId }))
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
buildTrainingUnitSavePayload,
createEmptyTrainingUnitFormData,
trainingUnitFormSnapshot,
validateTrainingUnitFormForSave,
} from './trainingUnitEditorCore.js'
@ -42,4 +43,11 @@ describe('trainingUnitEditorCore', () => {
expect(payload.assistant_trainer_profile_ids).toBeNull()
expect(payload.group_id).toBeUndefined()
})
it('trainingUnitFormSnapshot matches save payload', () => {
const formData = createEmptyTrainingUnitFormData({ groupId: '5', plannedDate: '2026-05-10' })
const snap = trainingUnitFormSnapshot(formData, { draftPlanTemplateId: '3' })
const payload = buildTrainingUnitSavePayload(formData, { draftPlanTemplateId: '3' })
expect(snap).toBe(JSON.stringify(payload))
})
})