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
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:
parent
472cf1afdb
commit
cb868373f4
|
|
@ -1,11 +1,15 @@
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
import PlanningRouteNav from '../components/planning/PlanningRouteNav'
|
import PlanningRouteNav from '../components/planning/PlanningRouteNav'
|
||||||
|
import { isPlanningUnitEditorPath } from '../utils/planningUnitRoutes'
|
||||||
|
|
||||||
/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */
|
/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */
|
||||||
export default function PlanningLayout() {
|
export default function PlanningLayout() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const hideRouteNav = isPlanningUnitEditorPath(pathname)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page planning-layout">
|
<div className="app-page planning-layout">
|
||||||
<PlanningRouteNav />
|
{!hideRouteNav ? <PlanningRouteNav /> : null}
|
||||||
<div className="planning-layout__main">
|
<div className="planning-layout__main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,12 @@ import {
|
||||||
createEmptyTrainingUnitFormData,
|
createEmptyTrainingUnitFormData,
|
||||||
buildTrainingUnitSavePayload,
|
buildTrainingUnitSavePayload,
|
||||||
trainingUnitToFormFields,
|
trainingUnitToFormFields,
|
||||||
|
trainingUnitFormSnapshot,
|
||||||
validateTrainingUnitFormForSave,
|
validateTrainingUnitFormForSave,
|
||||||
} from '../utils/trainingUnitEditorCore'
|
} from '../utils/trainingUnitEditorCore'
|
||||||
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
||||||
|
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||||
|
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||||
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
|
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
|
||||||
|
|
||||||
export default function TrainingUnitEditPage() {
|
export default function TrainingUnitEditPage() {
|
||||||
|
|
@ -54,6 +57,45 @@ export default function TrainingUnitEditPage() {
|
||||||
const formRef = useRef(formData)
|
const formRef = useRef(formData)
|
||||||
formRef.current = 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 [exercisePickerOpen, setExercisePickerOpen] = useState(false)
|
||||||
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
|
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
|
||||||
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
|
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
|
||||||
|
|
@ -400,10 +442,18 @@ export default function TrainingUnitEditPage() {
|
||||||
const reloadUnitAfterSave = useCallback(
|
const reloadUnitAfterSave = useCallback(
|
||||||
async (savedId) => {
|
async (savedId) => {
|
||||||
const fullUnit = await api.getTrainingUnit(savedId)
|
const fullUnit = await api.getTrainingUnit(savedId)
|
||||||
|
const nextDraftTemplateId = fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : ''
|
||||||
setEditingUnit(fullUnit)
|
setEditingUnit(fullUnit)
|
||||||
|
setDraftPlanTemplateId(nextDraftTemplateId)
|
||||||
let sections = normalizeUnitToForm(fullUnit)
|
let sections = normalizeUnitToForm(fullUnit)
|
||||||
sections = await enrichSectionsWithVariants(sections)
|
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) {
|
if (!isNew && savedId !== unitId) {
|
||||||
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
|
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +498,11 @@ export default function TrainingUnitEditPage() {
|
||||||
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
|
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleUnsavedDialogSave = async () => {
|
||||||
|
const ok = await handleSubmit(null, { closeAfter: false })
|
||||||
|
if (ok) blocker.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
const actionConfig = useMemo(
|
const actionConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
formId: 'planning-unit-form',
|
formId: 'planning-unit-form',
|
||||||
|
|
@ -726,6 +781,14 @@ export default function TrainingUnitEditPage() {
|
||||||
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
|
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
|
||||||
onClose={() => setPlanningPeekCtx(null)}
|
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>
|
</PageFormEditorChrome>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/** @param {ReturnType<typeof buildPlanningHubReturnState>|null|undefined} state */
|
||||||
export function planningHubPathFromReturnState(state) {
|
export function planningHubPathFromReturnState(state) {
|
||||||
if (!state || state.v !== 1) return PLANNING_HUB_PATH
|
if (!state || state.v !== 1) return PLANNING_HUB_PATH
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
buildPlanUnitEditPath,
|
buildPlanUnitEditPath,
|
||||||
buildPlanUnitNewPath,
|
buildPlanUnitNewPath,
|
||||||
buildPlanningHubReturnState,
|
buildPlanningHubReturnState,
|
||||||
|
isPlanningUnitEditorPath,
|
||||||
legacyPlanningUnitDeepLinkTarget,
|
legacyPlanningUnitDeepLinkTarget,
|
||||||
parsePlanningHubQuery,
|
parsePlanningHubQuery,
|
||||||
planningHubPathFromReturnState,
|
planningHubPathFromReturnState,
|
||||||
|
|
@ -22,6 +23,13 @@ describe('planningUnitRoutes', () => {
|
||||||
expect(buildPlanUnitNewPath()).toBe('/planning/units/new')
|
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', () => {
|
it('legacyPlanningUnitDeepLinkTarget', () => {
|
||||||
expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit')
|
expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit')
|
||||||
expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe(
|
expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe(
|
||||||
|
|
|
||||||
|
|
@ -131,3 +131,11 @@ export function validateTrainingUnitFormForSave(formData) {
|
||||||
}
|
}
|
||||||
return { ok: true }
|
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 }))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
buildTrainingUnitSavePayload,
|
buildTrainingUnitSavePayload,
|
||||||
createEmptyTrainingUnitFormData,
|
createEmptyTrainingUnitFormData,
|
||||||
|
trainingUnitFormSnapshot,
|
||||||
validateTrainingUnitFormForSave,
|
validateTrainingUnitFormForSave,
|
||||||
} from './trainingUnitEditorCore.js'
|
} from './trainingUnitEditorCore.js'
|
||||||
|
|
||||||
|
|
@ -42,4 +43,11 @@ describe('trainingUnitEditorCore', () => {
|
||||||
expect(payload.assistant_trainer_profile_ids).toBeNull()
|
expect(payload.assistant_trainer_profile_ids).toBeNull()
|
||||||
expect(payload.group_id).toBeUndefined()
|
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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user