From cb868373f4a212a0b63cdcb72bd5786a493a1e08 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 May 2026 14:39:46 +0200 Subject: [PATCH] Enhance PlanningLayout and TrainingUnitEditPage with unsaved changes handling - 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. --- frontend/src/layouts/PlanningLayout.jsx | 8 ++- frontend/src/pages/TrainingUnitEditPage.jsx | 65 ++++++++++++++++++- frontend/src/utils/planningUnitRoutes.js | 7 ++ frontend/src/utils/planningUnitRoutes.test.js | 8 +++ frontend/src/utils/trainingUnitEditorCore.js | 8 +++ .../src/utils/trainingUnitEditorCore.test.js | 8 +++ 6 files changed, 101 insertions(+), 3 deletions(-) diff --git a/frontend/src/layouts/PlanningLayout.jsx b/frontend/src/layouts/PlanningLayout.jsx index c11b1b5..1839a1b 100644 --- a/frontend/src/layouts/PlanningLayout.jsx +++ b/frontend/src/layouts/PlanningLayout.jsx @@ -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 (
- + {!hideRouteNav ? : null}
diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index a44eaf8..45b0745 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -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)} /> + + setBypassDirty(true)} + detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?" + /> ) } diff --git a/frontend/src/utils/planningUnitRoutes.js b/frontend/src/utils/planningUnitRoutes.js index fa639ac..ba69613 100644 --- a/frontend/src/utils/planningUnitRoutes.js +++ b/frontend/src/utils/planningUnitRoutes.js @@ -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|null|undefined} state */ export function planningHubPathFromReturnState(state) { if (!state || state.v !== 1) return PLANNING_HUB_PATH diff --git a/frontend/src/utils/planningUnitRoutes.test.js b/frontend/src/utils/planningUnitRoutes.test.js index 5df40e8..5b738ee 100644 --- a/frontend/src/utils/planningUnitRoutes.test.js +++ b/frontend/src/utils/planningUnitRoutes.test.js @@ -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( diff --git a/frontend/src/utils/trainingUnitEditorCore.js b/frontend/src/utils/trainingUnitEditorCore.js index 9b05e53..a03a905 100644 --- a/frontend/src/utils/trainingUnitEditorCore.js +++ b/frontend/src/utils/trainingUnitEditorCore.js @@ -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 })) +} diff --git a/frontend/src/utils/trainingUnitEditorCore.test.js b/frontend/src/utils/trainingUnitEditorCore.test.js index 3866e25..d320900 100644 --- a/frontend/src/utils/trainingUnitEditorCore.test.js +++ b/frontend/src/utils/trainingUnitEditorCore.test.js @@ -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)) + }) })