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
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:
parent
9b3f594007
commit
7f62b6ceee
|
|
@ -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“. */
|
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
|
||||||
function ExerciseVariantFields({
|
function ExerciseVariantFields({
|
||||||
row,
|
row,
|
||||||
|
|
@ -480,6 +500,24 @@ function ExerciseFormPageRoot() {
|
||||||
const [variantBusy, setVariantBusy] = useState(false)
|
const [variantBusy, setVariantBusy] = useState(false)
|
||||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||||
const variantsDetailsRef = useRef(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 [mediaFields, setMediaFields] = useState({})
|
||||||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||||
|
|
@ -588,9 +626,11 @@ function ExerciseFormPageRoot() {
|
||||||
try {
|
try {
|
||||||
const exercise = await api.getExercise(exerciseId)
|
const exercise = await api.getExercise(exerciseId)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
const variantRows = (exercise.variants || []).map(apiVariantToRow)
|
||||||
setFormData(detailToForm(exercise))
|
setFormData(detailToForm(exercise))
|
||||||
setMediaList(exercise.media || [])
|
setMediaList(exercise.media || [])
|
||||||
setVariants((exercise.variants || []).map(apiVariantToRow))
|
setVariants(variantRows)
|
||||||
|
syncVariantsSavedSnapshot(variantRows)
|
||||||
setVariantDraft(emptyVariantDraft())
|
setVariantDraft(emptyVariantDraft())
|
||||||
setVariantEditSelection(null)
|
setVariantEditSelection(null)
|
||||||
setFormDirty(false)
|
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(
|
const performSaveAttempt = useCallback(
|
||||||
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
||||||
if (!formData.title || formData.title.trim().length < 3) {
|
if (!formData.title || formData.title.trim().length < 3) {
|
||||||
toast.error('Titel mindestens 3 Zeichen')
|
toast.error('Titel mindestens 3 Zeichen')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (isEdit && exerciseId) {
|
||||||
|
const variantsOk = await persistPendingVariantChanges()
|
||||||
|
if (!variantsOk) return false
|
||||||
|
}
|
||||||
const payloadBase = {
|
const payloadBase = {
|
||||||
...formData,
|
...formData,
|
||||||
equipment:
|
equipment:
|
||||||
|
|
@ -876,7 +977,9 @@ function ExerciseFormPageRoot() {
|
||||||
}
|
}
|
||||||
const ex = await api.getExercise(exerciseId)
|
const ex = await api.getExercise(exerciseId)
|
||||||
setMediaList(ex.media || [])
|
setMediaList(ex.media || [])
|
||||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
const variantRows = (ex.variants || []).map(apiVariantToRow)
|
||||||
|
setVariants(variantRows)
|
||||||
|
syncVariantsSavedSnapshot(variantRows)
|
||||||
setFormDirty(false)
|
setFormDirty(false)
|
||||||
toast.success('Gespeichert.')
|
toast.success('Gespeichert.')
|
||||||
if (closeAfter) goBack()
|
if (closeAfter) goBack()
|
||||||
|
|
@ -898,7 +1001,7 @@ function ExerciseFormPageRoot() {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[exerciseId, formData, isEdit, navigate, location, toast, goBack],
|
[exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
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) => {
|
const updateVariantField = (id, patch) => {
|
||||||
setFormDirty(true)
|
setFormDirty(true)
|
||||||
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
||||||
|
|
@ -1863,7 +1960,8 @@ function ExerciseFormPageRoot() {
|
||||||
<div className="exercise-variants-details__body">
|
<div className="exercise-variants-details__body">
|
||||||
<p className="exercise-variants-hint">
|
<p className="exercise-variants-hint">
|
||||||
Pro Durchgang nur eine Variante bearbeiten – weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
|
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>
|
</p>
|
||||||
|
|
||||||
{variants.length > 0 && (
|
{variants.length > 0 && (
|
||||||
|
|
@ -1973,12 +2071,13 @@ function ExerciseFormPageRoot() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-secondary"
|
||||||
style={{ marginLeft: 'auto', fontSize: '12px' }}
|
style={{ marginLeft: 'auto', fontSize: '12px' }}
|
||||||
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
|
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
|
||||||
onClick={() => saveVariantRow(selectedVariantForEdit)}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user