Enhance navigation and return context in exercise and training module components
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Introduced a new `PageReturnLink` component for consistent back navigation across pages. - Updated `SaveSelectedExercisesAsModuleModal` and `SaveExercisesAsModuleModal` to utilize `navigateWithAppReturn`, preserving navigation context when redirecting after saving. - Enhanced `TrainingModuleEditPage` and `TrainingUnitEditPage` with improved return context handling, allowing users to navigate back to their previous locations seamlessly. - Added CSS styles for the new return link to improve visual consistency and user experience.
This commit is contained in:
parent
14b005e9b8
commit
6e6270b717
115
.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md
Normal file
115
.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Navigation — Return-Kontext (Rücksprung)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-20
|
||||||
|
**Status:** Spezifikation + schrittweise Umsetzung (Pilot)
|
||||||
|
**Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Viele Flows navigieren von Kontext A zu Editor/Detail B (z. B. Übungsliste → Modulbearbeitung). Die Zielseite kennt A nicht und bietet nur einen **fest verdrahteten** Zurück-Link (z. B. immer „Modul-Bibliothek“). In der installierten PWA fehlt zusätzlich die Browser-Chrome.
|
||||||
|
|
||||||
|
Betroffen u. a.:
|
||||||
|
|
||||||
|
- Übungsliste → Modul anlegen/bearbeiten
|
||||||
|
- Planung → Einheiten-Editor (teilweise gelöst via `planningReturn`)
|
||||||
|
- Modals mit Speichern + Redirect auf Vollseite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategie (Hybrid)
|
||||||
|
|
||||||
|
| Mechanismus | Wann |
|
||||||
|
|-------------|------|
|
||||||
|
| **Expliziter Return-Kontext** (`appReturn` in Router-State) | Seitenwechsel, bei denen das Ziel einen fachlichen Rücksprung anbieten soll |
|
||||||
|
| **History-Back** (`navigate(-1)`) | Fallback, wenn kein Kontext gesetzt ist und History-Eintrag existiert |
|
||||||
|
| **Default-Pfad** | Fallback der Zielseite (z. B. Modul-Bibliothek) |
|
||||||
|
| **Modal schließen** | Overlays/Peek — kein Routing-Return |
|
||||||
|
|
||||||
|
**Nicht** als alleinige Lösung: reines Browser-Back (History durch `replace`, Deep Links, Reload unzuverlässig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
Router-State-Schlüssel: **`appReturn`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
v: 1, // Schema-Version
|
||||||
|
path: '/exercises', // Ziel-URL (inkl. Query, falls nötig)
|
||||||
|
label: 'Zurück zur Übungsliste', // Anzeige im UI (vollständiger Satz)
|
||||||
|
kind: 'exerciseList', // optional: Typ für erweiterte Wiederherstellung
|
||||||
|
payload: { ... } // optional: kind-spezifische Daten
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `kind`-Werte (erweiterbar)
|
||||||
|
|
||||||
|
| kind | payload | path-Ableitung |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) |
|
||||||
|
| `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` |
|
||||||
|
| `trainingModulesList` | — | `/planning/training-modules` |
|
||||||
|
| (frei) | — | `path` direkt gesetzt |
|
||||||
|
|
||||||
|
### Legacy-Kompatibilität
|
||||||
|
|
||||||
|
Bestehendes Feld **`planningReturn`** (Planung ↔ Einheiten-Editor) wird beim Lesen in `appReturn` **bridged** — keine Big-Bang-Migration nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API (Frontend)
|
||||||
|
|
||||||
|
Zentrale Datei: `frontend/src/utils/navReturnContext.js`
|
||||||
|
|
||||||
|
| Funktion | Zweck |
|
||||||
|
|----------|--------|
|
||||||
|
| `buildNavReturnContext({ path, label, kind?, payload? })` | Kontext-Objekt erzeugen |
|
||||||
|
| `buildExercisesListReturnContext()` | Standard-Rückkehr Übungsliste |
|
||||||
|
| `buildPlanningHubReturnContext(hubState)` | Planungs-Hub inkl. Filter-Query |
|
||||||
|
| `buildTrainingModulesListReturnContext()` | Modul-Bibliothek |
|
||||||
|
| `readNavReturnFromLocation(location)` | Kontext aus `location.state` (+ Legacy) |
|
||||||
|
| `resolveNavReturnTarget(location, fallback)` | `{ path, label }` für UI |
|
||||||
|
| `goNavReturn(navigate, location, fallback?)` | Programmatischer Rücksprung (priorisiert: Kontext → History → Fallback) |
|
||||||
|
| `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` |
|
||||||
|
| `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z. B. nach `replace`) |
|
||||||
|
|
||||||
|
UI-Komponente: **`PageReturnLink`** — einheitlicher Zurück-Link oben auf Editor-/Detailseiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regeln für Entwickler
|
||||||
|
|
||||||
|
1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`).
|
||||||
|
2. **Zielseite** zeigt `PageReturnLink` mit sinnvollem **Default-Fallback** (Bibliothek/Hub).
|
||||||
|
3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten.
|
||||||
|
4. **Modals:** Schließen reicht; Redirect nach Speichern = Seiten-Navigation → Return setzen.
|
||||||
|
5. **Kein Return-Kontext** in `location.state` für interne Bibliothek → Detail → Bearbeiten, wenn Herkunft = offensichtliche Elternliste (Default-Fallback genügt).
|
||||||
|
6. **UI-State** (Filter, Auswahl): weiter über bestehende Session-Mechanismen (z. B. `exerciseListSessionState`), nicht im Return-Payload duplizieren, außer kind erfordert Query-Reconstruction (Planung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pilot-Umsetzung (Phase 1)
|
||||||
|
|
||||||
|
- [x] Spec + Utility + Tests
|
||||||
|
- [x] `PageReturnLink`
|
||||||
|
- [x] Übungsliste → Modul speichern → Modul-Editor (dynamischer Zurück-Link)
|
||||||
|
- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter
|
||||||
|
- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge)
|
||||||
|
|
||||||
|
## Folge-Phasen (noch offen)
|
||||||
|
|
||||||
|
- Weitere Editoren (Übung, Vorlage, Rahmenprogramm)
|
||||||
|
- Optional: globaler Zurück-Button in App-Chrome (Mobile)
|
||||||
|
- Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast
|
||||||
|
- `ExercisePeekModal` → Vollseite mit Return
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- Bestehend: `frontend/src/utils/planningUnitRoutes.js` (`planningReturn`)
|
||||||
|
- Session Übungsliste: `frontend/src/utils/exerciseListSessionState.js`
|
||||||
|
- PWA-Kontext: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`, App-Shell in `App.jsx`
|
||||||
|
|
@ -1459,6 +1459,21 @@ html.modal-scroll-locked .app-main {
|
||||||
.page-form-editor__intro {
|
.page-form-editor__intro {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.page-return-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.page-return-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.page-return-link {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.page-form-editor__back {
|
.page-form-editor__back {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
|
|
|
||||||
36
frontend/src/components/PageReturnLink.jsx
Normal file
36
frontend/src/components/PageReturnLink.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { goNavReturn, resolveNavReturnTarget } from '../utils/navReturnContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitlicher Zurück-Link für Editor-/Detailseiten (PWA-sicher).
|
||||||
|
*/
|
||||||
|
export default function PageReturnLink({
|
||||||
|
fallbackPath,
|
||||||
|
fallbackLabel,
|
||||||
|
className = 'page-return-link',
|
||||||
|
style,
|
||||||
|
}) {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const target = resolveNavReturnTarget(location, {
|
||||||
|
path: fallbackPath,
|
||||||
|
label: fallbackLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!target?.path || !target?.label) return null
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
goNavReturn(navigate, location, {
|
||||||
|
path: fallbackPath,
|
||||||
|
label: fallbackLabel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={target.path} className={className} style={style} onClick={handleClick}>
|
||||||
|
← {target.label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import ExerciseListSearchBar from './ExerciseListSearchBar'
|
||||||
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
||||||
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||||
import ExercisePeekModal from '../ExercisePeekModal'
|
import ExercisePeekModal from '../ExercisePeekModal'
|
||||||
|
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
||||||
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
||||||
|
|
@ -296,6 +297,8 @@ function ExercisesListPageRoot() {
|
||||||
|
|
||||||
const selectedExercisesInListOrder = selectedExercisesDisplay
|
const selectedExercisesInListOrder = selectedExercisesDisplay
|
||||||
|
|
||||||
|
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
|
||||||
|
|
||||||
const bulkVisibilityOptions = useMemo(() => {
|
const bulkVisibilityOptions = useMemo(() => {
|
||||||
const base = [
|
const base = [
|
||||||
{ id: '', label: '— nicht ändern —' },
|
{ id: '', label: '— nicht ändern —' },
|
||||||
|
|
@ -595,6 +598,7 @@ function ExercisesListPageRoot() {
|
||||||
open={saveModuleModalOpen}
|
open={saveModuleModalOpen}
|
||||||
onClose={() => setSaveModuleModalOpen(false)}
|
onClose={() => setSaveModuleModalOpen(false)}
|
||||||
selectedExercises={selectedExercisesInListOrder}
|
selectedExercises={selectedExercisesInListOrder}
|
||||||
|
returnContext={exercisesModuleReturnContext}
|
||||||
onSuccess={clearSelection}
|
onSuccess={clearSelection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useAuth } from '../../context/AuthContext'
|
||||||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
|
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
|
||||||
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
|
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
|
||||||
import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection'
|
import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection'
|
||||||
|
import { navigateWithAppReturn } from '../../utils/navReturnContext'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge).
|
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge).
|
||||||
|
|
@ -18,6 +19,8 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
onClose,
|
onClose,
|
||||||
/** @type {Array<{ id: number, title?: string }>} */
|
/** @type {Array<{ id: number, title?: string }>} */
|
||||||
selectedExercises,
|
selectedExercises,
|
||||||
|
/** Return-Kontext für Modul-Editor (z. B. Übungsliste) */
|
||||||
|
returnContext,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -153,7 +156,7 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
]
|
]
|
||||||
await api.updateTrainingModule(mid, { items: merged })
|
await api.updateTrainingModule(mid, { items: merged })
|
||||||
toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`)
|
toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`)
|
||||||
navigate(`/planning/training-modules/${mid}`)
|
navigateWithAppReturn(navigate, `/planning/training-modules/${mid}`, returnContext)
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
onClose()
|
onClose()
|
||||||
return
|
return
|
||||||
|
|
@ -184,7 +187,7 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
})
|
})
|
||||||
toast.success('Trainingsmodul gespeichert.')
|
toast.success('Trainingsmodul gespeichert.')
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
navigate(`/planning/training-modules/${created.id}`)
|
navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
|
||||||
}
|
}
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
onClose()
|
onClose()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useToast } from '../../context/ToastContext'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { activeClubMemberships } from '../../utils/activeClub'
|
import { activeClubMemberships } from '../../utils/activeClub'
|
||||||
import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
|
import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
|
||||||
|
import { navigateWithAppReturn } from '../../utils/navReturnContext'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
|
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
|
||||||
|
|
@ -16,6 +17,7 @@ export default function SaveExercisesAsModuleModal({
|
||||||
onClose,
|
onClose,
|
||||||
unitId,
|
unitId,
|
||||||
planningModalClubId,
|
planningModalClubId,
|
||||||
|
returnContext,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -134,7 +136,7 @@ export default function SaveExercisesAsModuleModal({
|
||||||
})
|
})
|
||||||
toast.success('Trainingsmodul gespeichert.')
|
toast.success('Trainingsmodul gespeichert.')
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
navigate(`/planning/training-modules/${created.id}`)
|
navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
|
||||||
}
|
}
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
onClose()
|
onClose()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import FormActionBar from '../components/FormActionBar'
|
import FormActionBar from '../components/FormActionBar'
|
||||||
|
import PageReturnLink from '../components/PageReturnLink'
|
||||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
|
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
|
import {
|
||||||
|
TRAINING_MODULES_LIST_PATH,
|
||||||
|
buildTrainingModulesListReturnContext,
|
||||||
|
goNavReturn,
|
||||||
|
preserveAppReturnOnNavigate,
|
||||||
|
} from '../utils/navReturnContext'
|
||||||
|
|
||||||
function moduleFormSnapshot({
|
function moduleFormSnapshot({
|
||||||
title,
|
title,
|
||||||
|
|
@ -62,6 +69,7 @@ function swapItems(arr, i, j) {
|
||||||
export default function TrainingModuleEditPage() {
|
export default function TrainingModuleEditPage() {
|
||||||
const { id: routeId } = useParams()
|
const { id: routeId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const isNew = !routeId || routeId === 'new'
|
const isNew = !routeId || routeId === 'new'
|
||||||
const moduleId = !isNew ? parseInt(routeId, 10) : NaN
|
const moduleId = !isNew ? parseInt(routeId, 10) : NaN
|
||||||
|
|
||||||
|
|
@ -302,6 +310,12 @@ export default function TrainingModuleEditPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moduleListReturn = useMemo(() => buildTrainingModulesListReturnContext(), [])
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
goNavReturn(navigate, location, moduleListReturn)
|
||||||
|
}, [navigate, location, moduleListReturn])
|
||||||
|
|
||||||
const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Titel ist Pflicht.')
|
toast.error('Titel ist Pflicht.')
|
||||||
|
|
@ -315,9 +329,11 @@ export default function TrainingModuleEditPage() {
|
||||||
const created = await api.createTrainingModule(body)
|
const created = await api.createTrainingModule(body)
|
||||||
toast.success('Trainingsmodul angelegt.')
|
toast.success('Trainingsmodul angelegt.')
|
||||||
if (closeAfter) {
|
if (closeAfter) {
|
||||||
navigate('/planning/training-modules')
|
goBack()
|
||||||
} else if (!fromUnsavedDialog) {
|
} else if (!fromUnsavedDialog) {
|
||||||
navigate(`/planning/training-modules/${created.id}`, { replace: true })
|
preserveAppReturnOnNavigate(navigate, location, `/planning/training-modules/${created.id}`, {
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +341,7 @@ export default function TrainingModuleEditPage() {
|
||||||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||||||
setBypassDirty(false)
|
setBypassDirty(false)
|
||||||
toast.success('Gespeichert.')
|
toast.success('Gespeichert.')
|
||||||
if (closeAfter) navigate('/planning/training-modules')
|
if (closeAfter) goBack()
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || 'Speichern fehlgeschlagen'
|
const msg = err.message || 'Speichern fehlgeschlagen'
|
||||||
|
|
@ -361,11 +377,10 @@ export default function TrainingModuleEditPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<p style={{ marginBottom: '0.75rem' }}>
|
<PageReturnLink
|
||||||
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
|
fallbackPath={TRAINING_MODULES_LIST_PATH}
|
||||||
← Zurück zur Modul‑Bibliothek
|
fallbackLabel="Zurück zur Modul-Bibliothek"
|
||||||
</Link>
|
/>
|
||||||
</p>
|
|
||||||
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
|
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
|
||||||
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
|
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
|
||||||
|
|
@ -670,7 +685,7 @@ export default function TrainingModuleEditPage() {
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onSave={() => handleSave()}
|
onSave={() => handleSave()}
|
||||||
onSaveAndClose={handleSaveAndClose}
|
onSaveAndClose={handleSaveAndClose}
|
||||||
onCancel={() => navigate('/planning/training-modules')}
|
onCancel={goBack}
|
||||||
cancelLabel="Abbrechen"
|
cancelLabel="Abbrechen"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
||||||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||||
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
|
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
|
||||||
|
import { goNavReturn, buildCurrentLocationReturnContext } from '../utils/navReturnContext'
|
||||||
|
|
||||||
export default function TrainingUnitEditPage() {
|
export default function TrainingUnitEditPage() {
|
||||||
const { id: routeId } = useParams()
|
const { id: routeId } = useParams()
|
||||||
|
|
@ -119,8 +120,16 @@ export default function TrainingUnitEditPage() {
|
||||||
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
navigate(planningHubPathFromReturnState(location.state?.planningReturn))
|
goNavReturn(navigate, location, {
|
||||||
}, [location.state, navigate])
|
path: planningHubPathFromReturnState(location.state?.planningReturn),
|
||||||
|
label: 'Zurück zur Planung',
|
||||||
|
})
|
||||||
|
}, [location, navigate])
|
||||||
|
|
||||||
|
const moduleSaveReturnContext = useMemo(
|
||||||
|
() => buildCurrentLocationReturnContext(location, 'Zurück zur Trainingseinheit'),
|
||||||
|
[location]
|
||||||
|
)
|
||||||
|
|
||||||
const planningClubId = useMemo(() => {
|
const planningClubId = useMemo(() => {
|
||||||
const gid = Number(formData.group_id)
|
const gid = Number(formData.group_id)
|
||||||
|
|
@ -731,6 +740,7 @@ export default function TrainingUnitEditPage() {
|
||||||
onSuccess={() => setSaveModuleOpen(false)}
|
onSuccess={() => setSaveModuleOpen(false)}
|
||||||
unitId={editingUnit?.id}
|
unitId={editingUnit?.id}
|
||||||
planningModalClubId={planningClubId}
|
planningModalClubId={planningClubId}
|
||||||
|
returnContext={moduleSaveReturnContext}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExercisePickerModal
|
<ExercisePickerModal
|
||||||
|
|
|
||||||
140
frontend/src/utils/navReturnContext.js
Normal file
140
frontend/src/utils/navReturnContext.js
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* Einheitlicher Rücksprung-Kontext für PWA-Navigation (siehe NAV_RETURN_CONTEXT_SPEC.md).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
buildPlanningHubReturnState,
|
||||||
|
planningHubPathFromReturnState,
|
||||||
|
} from './planningUnitRoutes'
|
||||||
|
|
||||||
|
export const NAV_RETURN_STATE_KEY = 'appReturn'
|
||||||
|
export const EXERCISES_LIST_PATH = '/exercises'
|
||||||
|
export const TRAINING_MODULES_LIST_PATH = '/planning/training-modules'
|
||||||
|
|
||||||
|
export function buildNavReturnContext(opts) {
|
||||||
|
const path = String(opts?.path || '').trim()
|
||||||
|
const label = String(opts?.label || '').trim()
|
||||||
|
if (!path || !label) return null
|
||||||
|
const ctx = { v: 1, path, label }
|
||||||
|
if (opts?.kind) ctx.kind = opts.kind
|
||||||
|
if (opts?.payload && typeof opts.payload === 'object') {
|
||||||
|
ctx.payload = opts.payload
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExercisesListReturnContext() {
|
||||||
|
return buildNavReturnContext({
|
||||||
|
path: EXERCISES_LIST_PATH,
|
||||||
|
label: 'Zurück zur Übungsliste',
|
||||||
|
kind: 'exerciseList',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrainingModulesListReturnContext() {
|
||||||
|
return buildNavReturnContext({
|
||||||
|
path: TRAINING_MODULES_LIST_PATH,
|
||||||
|
label: 'Zurück zur Modul-Bibliothek',
|
||||||
|
kind: 'trainingModulesList',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rücksprung zur aktuellen Route (z. B. Einheiten-Editor).
|
||||||
|
*/
|
||||||
|
export function buildCurrentLocationReturnContext(location, label) {
|
||||||
|
const path = `${location?.pathname || ''}${location?.search || ''}`.trim()
|
||||||
|
if (!path) return null
|
||||||
|
return buildNavReturnContext({
|
||||||
|
path,
|
||||||
|
label: String(label || 'Zurück').trim(),
|
||||||
|
kind: 'currentLocation',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ReturnType<typeof buildPlanningHubReturnState>|object} hubState */
|
||||||
|
export function buildPlanningHubReturnContext(hubState = {}) {
|
||||||
|
const payload = buildPlanningHubReturnState(hubState)
|
||||||
|
return buildNavReturnContext({
|
||||||
|
path: planningHubPathFromReturnState(payload),
|
||||||
|
label: 'Zurück zur Planung',
|
||||||
|
kind: 'planningHub',
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy planningReturn → appReturn */
|
||||||
|
export function appReturnFromPlanningReturn(planningReturn) {
|
||||||
|
if (!planningReturn || planningReturn.v !== 1) return null
|
||||||
|
return buildPlanningHubReturnContext(planningReturn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location
|
||||||
|
*/
|
||||||
|
export function readNavReturnFromLocation(location) {
|
||||||
|
const state = location?.state
|
||||||
|
if (!state || typeof state !== 'object') return null
|
||||||
|
const raw = state[NAV_RETURN_STATE_KEY]
|
||||||
|
if (raw?.v === 1 && raw.path && raw.label) return raw
|
||||||
|
if (state.planningReturn) return appReturnFromPlanningReturn(state.planningReturn)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location
|
||||||
|
* @param {{ path: string, label: string }|null|undefined} fallback
|
||||||
|
*/
|
||||||
|
export function resolveNavReturnTarget(location, fallback) {
|
||||||
|
const ctx = readNavReturnFromLocation(location)
|
||||||
|
if (ctx?.path && ctx?.label) {
|
||||||
|
return { path: ctx.path, label: ctx.label, fromContext: true }
|
||||||
|
}
|
||||||
|
if (fallback?.path && fallback?.label) {
|
||||||
|
return { path: fallback.path, label: fallback.label, fromContext: false }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('react-router-dom').NavigateFunction} navigate
|
||||||
|
* @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location
|
||||||
|
* @param {{ path?: string, label?: string }|null|undefined} fallback
|
||||||
|
*/
|
||||||
|
export function goNavReturn(navigate, location, fallback) {
|
||||||
|
const target = resolveNavReturnTarget(location, fallback)
|
||||||
|
if (target?.fromContext && target.path) {
|
||||||
|
navigate(target.path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||||
|
navigate(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fallback?.path) {
|
||||||
|
navigate(fallback.path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('react-router-dom').NavigateFunction} navigate
|
||||||
|
* @param {string} to
|
||||||
|
* @param {ReturnType<typeof buildNavReturnContext>|null|undefined} returnContext
|
||||||
|
* @param {object} [options]
|
||||||
|
*/
|
||||||
|
export function navigateWithAppReturn(navigate, to, returnContext, options = {}) {
|
||||||
|
const state = { ...(options.state || {}) }
|
||||||
|
if (returnContext) state[NAV_RETURN_STATE_KEY] = returnContext
|
||||||
|
navigate(to, { ...options, state })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestehenden appReturn (oder Legacy planningReturn) beim Weiterleiten erhalten.
|
||||||
|
*/
|
||||||
|
export function preserveAppReturnOnNavigate(navigate, location, to, options = {}) {
|
||||||
|
const existing = readNavReturnFromLocation(location)
|
||||||
|
const state = { ...(options.state || {}) }
|
||||||
|
if (existing) state[NAV_RETURN_STATE_KEY] = existing
|
||||||
|
navigate(to, { ...options, state })
|
||||||
|
}
|
||||||
85
frontend/src/utils/navReturnContext.test.js
Normal file
85
frontend/src/utils/navReturnContext.test.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
appReturnFromPlanningReturn,
|
||||||
|
buildExercisesListReturnContext,
|
||||||
|
buildNavReturnContext,
|
||||||
|
buildPlanningHubReturnContext,
|
||||||
|
goNavReturn,
|
||||||
|
readNavReturnFromLocation,
|
||||||
|
resolveNavReturnTarget,
|
||||||
|
} from './navReturnContext.js'
|
||||||
|
|
||||||
|
describe('navReturnContext', () => {
|
||||||
|
it('buildNavReturnContext requires path and label', () => {
|
||||||
|
expect(buildNavReturnContext({ path: '/x', label: 'Zurück' })).toEqual({
|
||||||
|
v: 1,
|
||||||
|
path: '/x',
|
||||||
|
label: 'Zurück',
|
||||||
|
})
|
||||||
|
expect(buildNavReturnContext({ path: '', label: 'X' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildExercisesListReturnContext', () => {
|
||||||
|
const ctx = buildExercisesListReturnContext()
|
||||||
|
expect(ctx.path).toBe('/exercises')
|
||||||
|
expect(ctx.kind).toBe('exerciseList')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readNavReturnFromLocation prefers appReturn', () => {
|
||||||
|
const ctx = buildExercisesListReturnContext()
|
||||||
|
expect(readNavReturnFromLocation({ state: { appReturn: ctx } })).toEqual(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('readNavReturnFromLocation bridges planningReturn', () => {
|
||||||
|
const loc = {
|
||||||
|
state: {
|
||||||
|
planningReturn: {
|
||||||
|
v: 1,
|
||||||
|
selectedGroupId: '3',
|
||||||
|
planView: 'list',
|
||||||
|
calendarMonthStr: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
planScope: 'group',
|
||||||
|
assignedToMeOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const ctx = readNavReturnFromLocation(loc)
|
||||||
|
expect(ctx?.kind).toBe('planningHub')
|
||||||
|
expect(ctx?.path).toContain('/planning')
|
||||||
|
expect(ctx?.label).toBe('Zurück zur Planung')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appReturnFromPlanningReturn', () => {
|
||||||
|
const ctx = appReturnFromPlanningReturn({
|
||||||
|
v: 1,
|
||||||
|
selectedGroupId: '',
|
||||||
|
planView: 'calendar',
|
||||||
|
calendarMonthStr: '2026-05',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
planScope: 'group',
|
||||||
|
assignedToMeOnly: false,
|
||||||
|
})
|
||||||
|
expect(ctx?.path).toContain('month=2026-05')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolveNavReturnTarget uses fallback when no state', () => {
|
||||||
|
const fb = { path: '/planning/training-modules', label: 'Zurück zur Modul-Bibliothek' }
|
||||||
|
expect(resolveNavReturnTarget({ state: null }, fb)).toEqual({ ...fb, fromContext: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goNavReturn navigates to context path first', () => {
|
||||||
|
const navigate = vi.fn()
|
||||||
|
const ctx = buildExercisesListReturnContext()
|
||||||
|
goNavReturn(navigate, { state: { appReturn: ctx } }, null)
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/exercises')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildPlanningHubReturnContext', () => {
|
||||||
|
const ctx = buildPlanningHubReturnContext({ selectedGroupId: '7', planView: 'list' })
|
||||||
|
expect(ctx?.path).toContain('group=7')
|
||||||
|
expect(ctx?.kind).toBe('planningHub')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user