UX Verbesserung, Navigationsspeicherung #40
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 {
|
||||
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 {
|
||||
display: inline-block;
|
||||
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 SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||
import ExercisePeekModal from '../ExercisePeekModal'
|
||||
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
||||
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
||||
|
|
@ -296,6 +297,8 @@ function ExercisesListPageRoot() {
|
|||
|
||||
const selectedExercisesInListOrder = selectedExercisesDisplay
|
||||
|
||||
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
|
||||
|
||||
const bulkVisibilityOptions = useMemo(() => {
|
||||
const base = [
|
||||
{ id: '', label: '— nicht ändern —' },
|
||||
|
|
@ -595,6 +598,7 @@ function ExercisesListPageRoot() {
|
|||
open={saveModuleModalOpen}
|
||||
onClose={() => setSaveModuleModalOpen(false)}
|
||||
selectedExercises={selectedExercisesInListOrder}
|
||||
returnContext={exercisesModuleReturnContext}
|
||||
onSuccess={clearSelection}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useAuth } from '../../context/AuthContext'
|
|||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
|
||||
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
|
||||
import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection'
|
||||
import { navigateWithAppReturn } from '../../utils/navReturnContext'
|
||||
|
||||
/**
|
||||
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge).
|
||||
|
|
@ -18,6 +19,8 @@ export default function SaveSelectedExercisesAsModuleModal({
|
|||
onClose,
|
||||
/** @type {Array<{ id: number, title?: string }>} */
|
||||
selectedExercises,
|
||||
/** Return-Kontext für Modul-Editor (z. B. Übungsliste) */
|
||||
returnContext,
|
||||
onSuccess,
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -153,7 +156,7 @@ export default function SaveSelectedExercisesAsModuleModal({
|
|||
]
|
||||
await api.updateTrainingModule(mid, { items: merged })
|
||||
toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`)
|
||||
navigate(`/planning/training-modules/${mid}`)
|
||||
navigateWithAppReturn(navigate, `/planning/training-modules/${mid}`, returnContext)
|
||||
onSuccess?.()
|
||||
onClose()
|
||||
return
|
||||
|
|
@ -184,7 +187,7 @@ export default function SaveSelectedExercisesAsModuleModal({
|
|||
})
|
||||
toast.success('Trainingsmodul gespeichert.')
|
||||
if (created?.id) {
|
||||
navigate(`/planning/training-modules/${created.id}`)
|
||||
navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
|
||||
}
|
||||
onSuccess?.()
|
||||
onClose()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useToast } from '../../context/ToastContext'
|
|||
import { useAuth } from '../../context/AuthContext'
|
||||
import { activeClubMemberships } from '../../utils/activeClub'
|
||||
import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
|
||||
import { navigateWithAppReturn } from '../../utils/navReturnContext'
|
||||
|
||||
/**
|
||||
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
|
||||
|
|
@ -16,6 +17,7 @@ export default function SaveExercisesAsModuleModal({
|
|||
onClose,
|
||||
unitId,
|
||||
planningModalClubId,
|
||||
returnContext,
|
||||
onSuccess,
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -134,7 +136,7 @@ export default function SaveExercisesAsModuleModal({
|
|||
})
|
||||
toast.success('Trainingsmodul gespeichert.')
|
||||
if (created?.id) {
|
||||
navigate(`/planning/training-modules/${created.id}`)
|
||||
navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
|
||||
}
|
||||
onSuccess?.()
|
||||
onClose()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
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 ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import FormActionBar from '../components/FormActionBar'
|
||||
import PageReturnLink from '../components/PageReturnLink'
|
||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import {
|
||||
TRAINING_MODULES_LIST_PATH,
|
||||
buildTrainingModulesListReturnContext,
|
||||
goNavReturn,
|
||||
preserveAppReturnOnNavigate,
|
||||
} from '../utils/navReturnContext'
|
||||
|
||||
function moduleFormSnapshot({
|
||||
title,
|
||||
|
|
@ -62,6 +69,7 @@ function swapItems(arr, i, j) {
|
|||
export default function TrainingModuleEditPage() {
|
||||
const { id: routeId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isNew = !routeId || routeId === 'new'
|
||||
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 } = {}) => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Titel ist Pflicht.')
|
||||
|
|
@ -315,9 +329,11 @@ export default function TrainingModuleEditPage() {
|
|||
const created = await api.createTrainingModule(body)
|
||||
toast.success('Trainingsmodul angelegt.')
|
||||
if (closeAfter) {
|
||||
navigate('/planning/training-modules')
|
||||
goBack()
|
||||
} else if (!fromUnsavedDialog) {
|
||||
navigate(`/planning/training-modules/${created.id}`, { replace: true })
|
||||
preserveAppReturnOnNavigate(navigate, location, `/planning/training-modules/${created.id}`, {
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -325,7 +341,7 @@ export default function TrainingModuleEditPage() {
|
|||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||||
setBypassDirty(false)
|
||||
toast.success('Gespeichert.')
|
||||
if (closeAfter) navigate('/planning/training-modules')
|
||||
if (closeAfter) goBack()
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Speichern fehlgeschlagen'
|
||||
|
|
@ -361,11 +377,10 @@ export default function TrainingModuleEditPage() {
|
|||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<p style={{ marginBottom: '0.75rem' }}>
|
||||
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||
← Zurück zur Modul‑Bibliothek
|
||||
</Link>
|
||||
</p>
|
||||
<PageReturnLink
|
||||
fallbackPath={TRAINING_MODULES_LIST_PATH}
|
||||
fallbackLabel="Zurück zur Modul-Bibliothek"
|
||||
/>
|
||||
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
|
||||
<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).
|
||||
|
|
@ -670,7 +685,7 @@ export default function TrainingModuleEditPage() {
|
|||
saving={saving}
|
||||
onSave={() => handleSave()}
|
||||
onSaveAndClose={handleSaveAndClose}
|
||||
onCancel={() => navigate('/planning/training-modules')}
|
||||
onCancel={goBack}
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
|||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
|
||||
import { goNavReturn, buildCurrentLocationReturnContext } from '../utils/navReturnContext'
|
||||
|
||||
export default function TrainingUnitEditPage() {
|
||||
const { id: routeId } = useParams()
|
||||
|
|
@ -119,8 +120,16 @@ export default function TrainingUnitEditPage() {
|
|||
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
navigate(planningHubPathFromReturnState(location.state?.planningReturn))
|
||||
}, [location.state, navigate])
|
||||
goNavReturn(navigate, location, {
|
||||
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 gid = Number(formData.group_id)
|
||||
|
|
@ -731,6 +740,7 @@ export default function TrainingUnitEditPage() {
|
|||
onSuccess={() => setSaveModuleOpen(false)}
|
||||
unitId={editingUnit?.id}
|
||||
planningModalClubId={planningClubId}
|
||||
returnContext={moduleSaveReturnContext}
|
||||
/>
|
||||
|
||||
<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