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
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:
parent
734d943d73
commit
6a9351874f
|
|
@ -7,7 +7,7 @@ import {
|
|||
useLocation,
|
||||
Outlet,
|
||||
} from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
|
||||
import { ToastProvider } from './context/ToastContext'
|
||||
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
|
||||
import DesktopSidebar from './components/DesktopSidebar'
|
||||
|
|
@ -145,22 +145,26 @@ function ProtectedLayout() {
|
|||
|
||||
return (
|
||||
<OrgInboxProvider user={user}>
|
||||
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
|
||||
<div className="app-shell">
|
||||
<div className="app-shell__column">
|
||||
<div className="app-header app-header--mobile app-header--mobile-stack">
|
||||
<div className="app-header-mobile__top">
|
||||
<div className="app-logo">🥋 Shinkan</div>
|
||||
<FormEditorActionsProvider>
|
||||
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
|
||||
<div className="app-shell">
|
||||
<div className="app-shell__column">
|
||||
<div className="app-header app-header--mobile app-header--mobile-stack">
|
||||
<div className="app-header-mobile__top">
|
||||
<div className="app-logo">🥋 Shinkan</div>
|
||||
</div>
|
||||
<ActiveClubSwitcher variant="mobile" />
|
||||
</div>
|
||||
<ActiveClubSwitcher variant="mobile" />
|
||||
<div className="app-main">
|
||||
<InactiveMembershipBanner />
|
||||
<Outlet />
|
||||
</div>
|
||||
<FormEditorBottomSlot>
|
||||
<Nav showAdminNav={showAdminNav} />
|
||||
</FormEditorBottomSlot>
|
||||
</div>
|
||||
<div className="app-main">
|
||||
<InactiveMembershipBanner />
|
||||
<Outlet />
|
||||
</div>
|
||||
<Nav showAdminNav={showAdminNav} />
|
||||
</div>
|
||||
</div>
|
||||
</FormEditorActionsProvider>
|
||||
</OrgInboxProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1450,61 +1450,87 @@ html.modal-scroll-locked .app-main {
|
|||
padding-right: var(--page-pad, 16px);
|
||||
}
|
||||
|
||||
/* Einheiten-Editor (Vollseite): Scroll im Formular, Action Bar am unteren Formularrand */
|
||||
.app-main:has(.planning-unit-edit-host) {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Vollseiten-Editor: Kopfzeile (Desktop) + Mobile-Dock statt Bottom-Nav */
|
||||
.page-form-editor__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
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) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.page-form-editor__intro {
|
||||
min-width: 0;
|
||||
}
|
||||
.planning-layout__main:has(.planning-unit-edit-host) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.page-form-editor__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--accent-dark);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.planning-unit-edit-host {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.page-form-editor__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.15rem, 4vw, 1.45rem);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.planning-unit-edit-host__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.page-form-editor__header-actions {
|
||||
display: none;
|
||||
}
|
||||
.page-form-shell--fill {
|
||||
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 {
|
||||
.page-form-editor__header-actions .form-action-bar {
|
||||
position: static;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.form-action-bar--page {
|
||||
flex-shrink: 0;
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */
|
||||
|
|
|
|||
48
frontend/src/components/PageFormEditorChrome.jsx
Normal file
48
frontend/src/components/PageFormEditorChrome.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import FormActionBar from '../FormActionBar'
|
||||
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
||||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||
import { activeClubMemberships } from '../../utils/activeClub'
|
||||
|
|
@ -16,7 +15,6 @@ export default function TrainingUnitFormShell({
|
|||
setFormData,
|
||||
onSaveOnly,
|
||||
onSaveAndClose,
|
||||
onCancel,
|
||||
draftPlanTemplateId,
|
||||
onDraftTemplateSelect,
|
||||
planTemplates,
|
||||
|
|
@ -33,7 +31,6 @@ export default function TrainingUnitFormShell({
|
|||
onRequestTrainingModulePick,
|
||||
onRequestExercisePick,
|
||||
onPeekExercise,
|
||||
saving = false,
|
||||
formId = 'planning-unit-form',
|
||||
}) {
|
||||
const [newTplVisibility, setNewTplVisibility] = useState('private')
|
||||
|
|
@ -52,14 +49,9 @@ export default function TrainingUnitFormShell({
|
|||
}, [planningClubId, memberClubs])
|
||||
|
||||
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
|
||||
id={formId}
|
||||
className="card page-form-shell page-form-shell--fill"
|
||||
className="card page-form-shell"
|
||||
style={{ padding: 'clamp(14px, 3vw, 1.75rem)' }}
|
||||
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))}
|
||||
>
|
||||
|
|
@ -488,20 +480,6 @@ export default function TrainingUnitFormShell({
|
|||
/>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
38
frontend/src/context/FormEditorActionsContext.jsx
Normal file
38
frontend/src/context/FormEditorActionsContext.jsx
Normal 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])
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
trainingUnitToFormFields,
|
||||
validateTrainingUnitFormForSave,
|
||||
} from '../utils/trainingUnitEditorCore'
|
||||
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
||||
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
|
||||
|
||||
export default function TrainingUnitEditPage() {
|
||||
|
|
@ -396,50 +397,73 @@ export default function TrainingUnitEditPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const reloadUnitAfterSave = async (savedId) => {
|
||||
const fullUnit = await api.getTrainingUnit(savedId)
|
||||
setEditingUnit(fullUnit)
|
||||
let sections = normalizeUnitToForm(fullUnit)
|
||||
sections = await enrichSectionsWithVariants(sections)
|
||||
setFormData(trainingUnitToFormFields(fullUnit, sections))
|
||||
if (!isNew && savedId !== unitId) {
|
||||
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
|
||||
}
|
||||
}
|
||||
const reloadUnitAfterSave = useCallback(
|
||||
async (savedId) => {
|
||||
const fullUnit = await api.getTrainingUnit(savedId)
|
||||
setEditingUnit(fullUnit)
|
||||
let sections = normalizeUnitToForm(fullUnit)
|
||||
sections = await enrichSectionsWithVariants(sections)
|
||||
setFormData(trainingUnitToFormFields(fullUnit, sections))
|
||||
if (!isNew && savedId !== unitId) {
|
||||
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
|
||||
}
|
||||
},
|
||||
[isNew, unitId, navigate, location.state]
|
||||
)
|
||||
|
||||
const handleSubmit = async (e, { closeAfter = true } = {}) => {
|
||||
e?.preventDefault?.()
|
||||
const v = validateTrainingUnitFormForSave(formData)
|
||||
if (!v.ok) {
|
||||
toast.error(v.message)
|
||||
return false
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = buildTrainingUnitSavePayload(formData, {
|
||||
editingUnit,
|
||||
draftPlanTemplateId,
|
||||
})
|
||||
let savedUnit
|
||||
if (editingUnit) {
|
||||
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
|
||||
} else {
|
||||
savedUnit = await api.createTrainingUnit(payload)
|
||||
const handleSubmit = useCallback(
|
||||
async (e, { closeAfter = true } = {}) => {
|
||||
e?.preventDefault?.()
|
||||
const v = validateTrainingUnitFormForSave(formData)
|
||||
if (!v.ok) {
|
||||
toast.error(v.message)
|
||||
return false
|
||||
}
|
||||
toast.success('Gespeichert.')
|
||||
if (closeAfter) {
|
||||
goBack()
|
||||
} else if (savedUnit?.id) {
|
||||
await reloadUnitAfterSave(savedUnit.id)
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = buildTrainingUnitSavePayload(formData, {
|
||||
editingUnit,
|
||||
draftPlanTemplateId,
|
||||
})
|
||||
let savedUnit
|
||||
if (editingUnit) {
|
||||
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
|
||||
} else {
|
||||
savedUnit = await api.createTrainingUnit(payload)
|
||||
}
|
||||
toast.success('Gespeichert.')
|
||||
if (closeAfter) {
|
||||
goBack()
|
||||
} else if (savedUnit?.id) {
|
||||
await reloadUnitAfterSave(savedUnit.id)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error('Fehler beim Speichern: ' + err.message)
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error('Fehler beim Speichern: ' + err.message)
|
||||
return false
|
||||
} finally {
|
||||
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 name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
|
||||
|
|
@ -568,13 +592,13 @@ export default function TrainingUnitEditPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="planning-unit-edit-host">
|
||||
<p style={{ marginBottom: '0.75rem', flexShrink: 0 }}>
|
||||
<Link to={planningHubPathFromReturnState(location.state?.planningReturn)} style={{ color: 'var(--accent-dark)' }}>
|
||||
← Zurück zur Trainingsplanung
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<PageFormEditorChrome
|
||||
testId="planning-unit-form"
|
||||
title={pageTitle}
|
||||
backTo={hubBackPath}
|
||||
backLabel="Trainingsplanung"
|
||||
actionConfig={actionConfig}
|
||||
>
|
||||
<TrainingUnitFormShell
|
||||
editingUnit={editingUnit}
|
||||
formData={formData}
|
||||
|
|
@ -582,7 +606,6 @@ export default function TrainingUnitEditPage() {
|
|||
setFormData={setFormData}
|
||||
onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
|
||||
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
|
||||
onCancel={goBack}
|
||||
draftPlanTemplateId={draftPlanTemplateId}
|
||||
onDraftTemplateSelect={applyTemplateFromSelect}
|
||||
planTemplates={planTemplates}
|
||||
|
|
@ -611,7 +634,6 @@ export default function TrainingUnitEditPage() {
|
|||
onPeekExercise={(id, variantId, peekExtras) =>
|
||||
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
|
||||
}
|
||||
saving={saving}
|
||||
/>
|
||||
|
||||
<TrainingPlanningModuleApplyModal
|
||||
|
|
@ -704,6 +726,6 @@ export default function TrainingUnitEditPage() {
|
|||
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
|
||||
onClose={() => setPlanningPeekCtx(null)}
|
||||
/>
|
||||
</div>
|
||||
</PageFormEditorChrome>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user