Enhance full-page editor layout and integrate form editor actions
Some checks failed
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 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 6m4s

- Updated CSS styles for the full-page editor, introducing a sticky header and mobile dock for improved navigation.
- Refactored App component to include FormEditorActionsProvider and FormEditorBottomSlot for better form handling.
- Simplified TrainingUnitFormShell by removing the FormActionBar, streamlining the form structure and enhancing usability.
This commit is contained in:
Lars 2026-05-19 11:18:54 +02:00
parent 734d943d73
commit 6a9351874f
6 changed files with 253 additions and 137 deletions

View File

@ -7,7 +7,7 @@ import {
useLocation, useLocation,
Outlet, Outlet,
} from 'react-router-dom' } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
import { ToastProvider } from './context/ToastContext' import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar' import DesktopSidebar from './components/DesktopSidebar'
@ -145,6 +145,7 @@ function ProtectedLayout() {
return ( return (
<OrgInboxProvider user={user}> <OrgInboxProvider user={user}>
<FormEditorActionsProvider>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} /> <DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell"> <div className="app-shell">
<div className="app-shell__column"> <div className="app-shell__column">
@ -158,9 +159,12 @@ function ProtectedLayout() {
<InactiveMembershipBanner /> <InactiveMembershipBanner />
<Outlet /> <Outlet />
</div> </div>
<FormEditorBottomSlot>
<Nav showAdminNav={showAdminNav} /> <Nav showAdminNav={showAdminNav} />
</FormEditorBottomSlot>
</div> </div>
</div> </div>
</FormEditorActionsProvider>
</OrgInboxProvider> </OrgInboxProvider>
) )
} }

View File

@ -1450,61 +1450,87 @@ html.modal-scroll-locked .app-main {
padding-right: var(--page-pad, 16px); padding-right: var(--page-pad, 16px);
} }
/* Einheiten-Editor (Vollseite): Scroll im Formular, Action Bar am unteren Formularrand */ /* Vollseiten-Editor: Kopfzeile (Desktop) + Mobile-Dock statt Bottom-Nav */
.app-main:has(.planning-unit-edit-host) { .page-form-editor__header {
overflow: hidden; position: sticky;
display: flex; top: 0;
flex-direction: column; z-index: 12;
background: var(--bg);
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
} }
.planning-layout:has(.planning-unit-edit-host) { .page-form-editor__intro {
flex: 1 1 auto; min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.planning-layout__main:has(.planning-unit-edit-host) { .page-form-editor__back {
flex: 1 1 auto; display: inline-block;
min-height: 0; margin-bottom: 0.35rem;
display: flex; color: var(--accent-dark);
flex-direction: column; font-weight: 600;
overflow: hidden; font-size: 0.9rem;
text-decoration: none;
} }
.planning-unit-edit-host { .page-form-editor__title {
flex: 1 1 auto; margin: 0;
min-height: 0; font-size: clamp(1.15rem, 4vw, 1.45rem);
display: flex; line-height: 1.25;
flex-direction: column;
overflow: hidden;
} }
.planning-unit-edit-host__body { .page-form-editor__header-actions {
flex: 1 1 auto; display: none;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.page-form-shell--fill { .page-form-editor__header-actions .form-action-bar {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.page-form-shell--fill .page-form-shell__scroll {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
overscroll-behavior-x: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.page-form-shell--fill .form-action-bar {
position: static; position: static;
margin-top: auto; border: none;
flex-shrink: 0; box-shadow: none;
padding: 0;
margin: 0;
background: transparent;
}
.form-editor-mobile-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
width: auto;
max-width: none;
background: var(--surface);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
padding: var(--nav-pad-top, 8px) max(10px, env(safe-area-inset-right, 0px))
env(safe-area-inset-bottom, 0px) max(10px, env(safe-area-inset-left, 0px));
box-sizing: border-box;
}
.form-editor-mobile-dock .form-action-bar {
position: static;
border: none;
box-shadow: none;
margin: 0;
}
@media (min-width: 1024px) {
.page-form-editor__header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 16px;
}
.page-form-editor__header-actions {
display: block;
flex: 1 1 320px;
min-width: min(100%, 420px);
}
.form-editor-mobile-dock {
display: none !important;
}
}
@media (max-width: 1023px) {
.app-shell__column:has(.form-editor-mobile-dock) .app-main {
padding-bottom: calc(52px + var(--nav-pad-top, 8px) + env(safe-area-inset-bottom, 0px) + 12px);
} }
.form-action-bar--page {
flex-shrink: 0;
} }
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */ /* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */

View File

@ -0,0 +1,48 @@
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import FormActionBar from './FormActionBar'
import { useFormEditorActions } from '../context/FormEditorActionsContext'
/**
* Vollseiten-Editor: sticky Kopfzeile (Desktop) + Mobile-Dock via FormEditorActionsProvider.
*/
export default function PageFormEditorChrome({
title,
backTo,
backLabel = 'Zurück',
actionConfig,
children,
testId,
}) {
useFormEditorActions(actionConfig)
const headerBar = useMemo(() => {
if (!actionConfig) return null
return (
<FormActionBar
{...actionConfig}
placement="top"
variant="page"
/>
)
}, [actionConfig])
return (
<div className="page-form-editor" data-testid={testId}>
<header className="page-form-editor__header">
<div className="page-form-editor__intro">
{backTo ? (
<Link to={backTo} className="page-form-editor__back">
{backLabel}
</Link>
) : null}
<h1 className="page-form-editor__title">{title}</h1>
</div>
{headerBar ? (
<div className="page-form-editor__header-actions">{headerBar}</div>
) : null}
</header>
<div className="page-form-editor__body">{children}</div>
</div>
)
}

View File

@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel' import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub' import { activeClubMemberships } from '../../utils/activeClub'
@ -16,7 +15,6 @@ export default function TrainingUnitFormShell({
setFormData, setFormData,
onSaveOnly, onSaveOnly,
onSaveAndClose, onSaveAndClose,
onCancel,
draftPlanTemplateId, draftPlanTemplateId,
onDraftTemplateSelect, onDraftTemplateSelect,
planTemplates, planTemplates,
@ -33,7 +31,6 @@ export default function TrainingUnitFormShell({
onRequestTrainingModulePick, onRequestTrainingModulePick,
onRequestExercisePick, onRequestExercisePick,
onPeekExercise, onPeekExercise,
saving = false,
formId = 'planning-unit-form', formId = 'planning-unit-form',
}) { }) {
const [newTplVisibility, setNewTplVisibility] = useState('private') const [newTplVisibility, setNewTplVisibility] = useState('private')
@ -52,14 +49,9 @@ export default function TrainingUnitFormShell({
}, [planningClubId, memberClubs]) }, [planningClubId, memberClubs])
return ( return (
<div className="planning-unit-edit-host__body" data-testid="planning-unit-form">
<h1 className="page-title" style={{ marginBottom: '1rem', flexShrink: 0 }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h1>
<form <form
id={formId} id={formId}
className="card page-form-shell page-form-shell--fill" className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)' }} style={{ padding: 'clamp(14px, 3vw, 1.75rem)' }}
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))} onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))}
> >
@ -488,20 +480,6 @@ export default function TrainingUnitFormShell({
/> />
</div> </div>
</div> </div>
<FormActionBar
placement="bottom"
variant="page"
formId={formId}
saving={saving}
isNew={!editingUnit}
onSave={onSaveOnly ? () => onSaveOnly() : undefined}
onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
onCancel={onCancel}
showSave={Boolean(onSaveOnly)}
showSaveAndClose
/>
</form> </form>
</div>
) )
} }

View File

@ -0,0 +1,38 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'
import FormActionBar from '../components/FormActionBar'
const FormEditorActionsContext = createContext(null)
export function FormEditorActionsProvider({ children }) {
const [config, setConfig] = useState(null)
const value = useMemo(() => ({ config, setConfig }), [config])
return <FormEditorActionsContext.Provider value={value}>{children}</FormEditorActionsContext.Provider>
}
/** Mobile: FormActionBar statt Bottom-Nav; Desktop: Fallback (Nav ist ohnehin ausgeblendet). */
export function FormEditorBottomSlot({ children }) {
const ctx = useContext(FormEditorActionsContext)
const config = ctx?.config
if (config) {
return (
<div className="form-editor-mobile-dock" data-testid="form-editor-mobile-dock">
<FormActionBar {...config} placement="bottom" variant="page" />
</div>
)
}
return children
}
/** @param {Record<string, unknown> | null} config FormActionBar-Props */
export function useFormEditorActions(config) {
const setConfig = useContext(FormEditorActionsContext)?.setConfig
useEffect(() => {
if (!setConfig) return undefined
setConfig(config ?? null)
return () => setConfig(null)
}, [setConfig, config])
}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@ -28,6 +28,7 @@ import {
trainingUnitToFormFields, trainingUnitToFormFields,
validateTrainingUnitFormForSave, validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore' } from '../utils/trainingUnitEditorCore'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes' import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
export default function TrainingUnitEditPage() { export default function TrainingUnitEditPage() {
@ -396,7 +397,8 @@ export default function TrainingUnitEditPage() {
} }
} }
const reloadUnitAfterSave = async (savedId) => { const reloadUnitAfterSave = useCallback(
async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId) const fullUnit = await api.getTrainingUnit(savedId)
setEditingUnit(fullUnit) setEditingUnit(fullUnit)
let sections = normalizeUnitToForm(fullUnit) let sections = normalizeUnitToForm(fullUnit)
@ -405,9 +407,12 @@ export default function TrainingUnitEditPage() {
if (!isNew && savedId !== unitId) { if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state }) navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
} }
} },
[isNew, unitId, navigate, location.state]
)
const handleSubmit = async (e, { closeAfter = true } = {}) => { const handleSubmit = useCallback(
async (e, { closeAfter = true } = {}) => {
e?.preventDefault?.() e?.preventDefault?.()
const v = validateTrainingUnitFormForSave(formData) const v = validateTrainingUnitFormForSave(formData)
if (!v.ok) { if (!v.ok) {
@ -439,7 +444,26 @@ export default function TrainingUnitEditPage() {
} finally { } finally {
setSaving(false) setSaving(false)
} }
} },
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
)
const actionConfig = useMemo(
() => ({
formId: 'planning-unit-form',
saving,
isNew: !editingUnit,
onSave: (e) => handleSubmit(e, { closeAfter: false }),
onSaveAndClose: (e) => handleSubmit(e, { closeAfter: true }),
onCancel: goBack,
showSave: true,
showSaveAndClose: true,
}),
[saving, editingUnit, handleSubmit, goBack]
)
const hubBackPath = planningHubPathFromReturnState(location.state?.planningReturn)
const pageTitle = editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'
const handleSaveAsTemplate = async (opts = {}) => { const handleSaveAsTemplate = async (opts = {}) => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
@ -568,13 +592,13 @@ export default function TrainingUnitEditPage() {
} }
return ( return (
<div className="planning-unit-edit-host"> <PageFormEditorChrome
<p style={{ marginBottom: '0.75rem', flexShrink: 0 }}> testId="planning-unit-form"
<Link to={planningHubPathFromReturnState(location.state?.planningReturn)} style={{ color: 'var(--accent-dark)' }}> title={pageTitle}
Zurück zur Trainingsplanung backTo={hubBackPath}
</Link> backLabel="Trainingsplanung"
</p> actionConfig={actionConfig}
>
<TrainingUnitFormShell <TrainingUnitFormShell
editingUnit={editingUnit} editingUnit={editingUnit}
formData={formData} formData={formData}
@ -582,7 +606,6 @@ export default function TrainingUnitEditPage() {
setFormData={setFormData} setFormData={setFormData}
onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })} onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })} onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
onCancel={goBack}
draftPlanTemplateId={draftPlanTemplateId} draftPlanTemplateId={draftPlanTemplateId}
onDraftTemplateSelect={applyTemplateFromSelect} onDraftTemplateSelect={applyTemplateFromSelect}
planTemplates={planTemplates} planTemplates={planTemplates}
@ -611,7 +634,6 @@ export default function TrainingUnitEditPage() {
onPeekExercise={(id, variantId, peekExtras) => onPeekExercise={(id, variantId, peekExtras) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null }) setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
} }
saving={saving}
/> />
<TrainingPlanningModuleApplyModal <TrainingPlanningModuleApplyModal
@ -704,6 +726,6 @@ export default function TrainingUnitEditPage() {
peekExtras={planningPeekCtx?.peekExtras ?? undefined} peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)} onClose={() => setPlanningPeekCtx(null)}
/> />
</div> </PageFormEditorChrome>
) )
} }