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 { 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user