Enhance Exercise Form Functionality with Variant Management Improvements
All checks were successful
Deploy Development / deploy (push) Successful in 38s
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 33s
Test Suite / playwright-tests (push) Successful in 1m22s

- Introduced snapshot and dirty check functions for variant payloads, enabling better tracking of unsaved changes.
- Implemented synchronization of saved variant snapshots to improve data integrity during edits.
- Enhanced the variant saving process with validation for variant names and automatic saving of changes.
- Updated the UI to reflect changes in variant management, ensuring a smoother user experience when editing exercise variants.
This commit is contained in:
Lars 2026-05-21 14:54:32 +02:00
parent 9b3f594007
commit 7f62b6ceee

View File

@ -190,6 +190,26 @@ function buildVariantPayloadFromRow(row) {
}
}
function snapshotVariantPayload(row) {
return JSON.stringify(buildVariantPayloadFromRow(row))
}
function variantDraftHasContent(draft) {
if (!draft) return false
const p = buildVariantPayloadFromRow(draft)
return (
p.variant_name.length > 0 ||
Boolean(p.description) ||
Boolean(p.execution_changes) ||
p.duration_min != null ||
p.duration_max != null ||
(Array.isArray(p.equipment_changes) && p.equipment_changes.length > 0) ||
Boolean(p.difficulty_adjustment) ||
(p.progression_level != null && p.progression_level !== 1) ||
p.prerequisite_variant_id != null
)
}
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
function ExerciseVariantFields({
row,
@ -480,6 +500,24 @@ function ExerciseFormPageRoot() {
const [variantBusy, setVariantBusy] = useState(false)
const [variantEditSelection, setVariantEditSelection] = useState(null)
const variantsDetailsRef = useRef(null)
const variantsSavedSnapshotRef = useRef({})
const syncVariantsSavedSnapshot = useCallback((rows) => {
const snap = {}
for (const v of rows || []) {
if (v?.id != null) snap[v.id] = snapshotVariantPayload(v)
}
variantsSavedSnapshotRef.current = snap
}, [])
const getDirtyVariantRows = useCallback((rows) => {
return (rows || []).filter((v) => {
if (v?.id == null) return false
const saved = variantsSavedSnapshotRef.current[v.id]
if (saved == null) return true
return snapshotVariantPayload(v) !== saved
})
}, [])
const [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null)
@ -588,9 +626,11 @@ function ExerciseFormPageRoot() {
try {
const exercise = await api.getExercise(exerciseId)
if (cancelled) return
const variantRows = (exercise.variants || []).map(apiVariantToRow)
setFormData(detailToForm(exercise))
setMediaList(exercise.media || [])
setVariants((exercise.variants || []).map(apiVariantToRow))
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
@ -774,12 +814,73 @@ function ExerciseFormPageRoot() {
)
}
const refreshVariants = useCallback(async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
const rows = (ex.variants || []).map(apiVariantToRow)
syncVariantsSavedSnapshot(rows)
setVariants(rows)
}, [exerciseId, syncVariantsSavedSnapshot])
const persistPendingVariantChanges = useCallback(async () => {
if (!exerciseId) return true
const dirtyRows = getDirtyVariantRows(variants)
if (dirtyRows.length > 0) {
setVariantBusy(true)
try {
for (const row of dirtyRows) {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
toast.error(`Variante „${row.variant_name || `#${row.id}`}“: Name mindestens 3 Zeichen`)
return false
}
setVariantSavingId(row.id)
await api.updateExerciseVariant(exerciseId, row.id, payload)
}
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantSavingId(null)
setVariantBusy(false)
}
}
if (variantDraftHasContent(variantDraft)) {
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
toast.error('Variantenentwurf: Name mindestens 3 Zeichen, sonst Felder verwerfen oder ausfüllen.')
return false
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
if (created?.id != null) setVariantEditSelection(created.id)
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantBusy(false)
}
}
return true
}, [exerciseId, variantDraft, variants, getDirtyVariantRows, refreshVariants, toast])
const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!formData.title || formData.title.trim().length < 3) {
toast.error('Titel mindestens 3 Zeichen')
return false
}
if (isEdit && exerciseId) {
const variantsOk = await persistPendingVariantChanges()
if (!variantsOk) return false
}
const payloadBase = {
...formData,
equipment:
@ -876,7 +977,9 @@ function ExerciseFormPageRoot() {
}
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
const variantRows = (ex.variants || []).map(apiVariantToRow)
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setFormDirty(false)
toast.success('Gespeichert.')
if (closeAfter) goBack()
@ -898,7 +1001,7 @@ function ExerciseFormPageRoot() {
setSaving(false)
}
},
[exerciseId, formData, isEdit, navigate, location, toast, goBack],
[exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
)
const handleSubmit = useCallback(
@ -1028,12 +1131,6 @@ function ExerciseFormPageRoot() {
}
}
const refreshVariants = async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
setVariants((ex.variants || []).map(apiVariantToRow))
}
const updateVariantField = (id, patch) => {
setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
@ -1863,7 +1960,8 @@ function ExerciseFormPageRoot() {
<div className="exercise-variants-details__body">
<p className="exercise-variants-hint">
Pro Durchgang nur eine Variante bearbeiten weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
Training; Voraussetzung nutzt ihr später für Progressions-Serien.
Training; Voraussetzung nutzt ihr später für Progressions-Serien. Offene Varianten-Änderungen werden mit
Speichern in der Aktionsleiste (oder in der Sicherheitsabfrage beim Verlassen) automatisch mitgespeichert.
</p>
{variants.length > 0 && (
@ -1973,12 +2071,13 @@ function ExerciseFormPageRoot() {
</button>
<button
type="button"
className="btn btn-primary"
className="btn btn-secondary"
style={{ marginLeft: 'auto', fontSize: '12px' }}
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
onClick={() => saveVariantRow(selectedVariantForEdit)}
title="Optional — Änderungen werden auch über die Aktionsleiste gespeichert"
>
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Variante jetzt speichern'}
</button>
<button
type="button"