Enhance TrainingUnitSectionsEditor with new drag-and-drop functionality for parallel phases
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s

- Added new event handlers for drag-and-drop operations to manage sections above and below split headers in parallel phases.
- Implemented utility functions to reorder sections as whole groups or as the first entry in parallel phases, improving section management.
- Updated CSS styles to visually represent new drop zones for sections, enhancing user experience during reordering.
- Refactored existing logic to accommodate new features and ensure proper handling of section placements within parallel streams.
This commit is contained in:
Lars 2026-05-15 10:59:30 +02:00
parent 73975d3402
commit 72e8f31cff
3 changed files with 286 additions and 13 deletions

View File

@ -5990,6 +5990,23 @@ a.analysis-split__nav-item {
width: 100%;
}
/* Parallele Phase: feste Einfügebänder oberhalb/unterhalb des Split-Headers */
.tu-phase-drop--above-split,
.tu-phase-drop--below-split {
margin: 6px 2px 8px;
min-height: 14px;
}
.tu-section-dropband--phase-parallel-slot {
height: 14px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
hsl(200 30% 94%) 100%
);
border: 1px dashed color-mix(in srgb, var(--accent) 38%, transparent);
}
.tu-sec-drag-grip {
flex-shrink: 0;
display: inline-flex;

View File

@ -24,6 +24,8 @@ import {
phaseRunsFromSections,
swapAdjacentPhaseRuns,
reorderBlocksImmutableWithPlanLoc,
reorderSectionBeforeParallelRunAsWholeGroup,
reorderSectionAsFirstInParallelPhase,
reorderBlockIntoParallelStreamEnd,
globalInsertBeforeIndexForParallelStreamEnd,
movePhaseRunUpByPhaseOrder,
@ -729,6 +731,145 @@ export default function TrainingUnitSectionsEditor({
setDropSectionBand({ slot: sectionToSlot, beforeIdx })
}
const onPhaseAboveSplitDragOver = (e, po) => {
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
if (!dtHasType(e, DND_TU_SECTION)) return
e.preventDefault()
e.stopPropagation()
try {
e.dataTransfer.dropEffect = 'move'
} catch {
/* ignore */
}
setDropSectionBand({ slot: sectionToSlot, phaseAboveSplitPo: Number(po) || 0 })
}
const onPhaseBelowSplitDragOver = (e, po) => {
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
if (!dtHasType(e, DND_TU_SECTION)) return
e.preventDefault()
e.stopPropagation()
try {
e.dataTransfer.dropEffect = 'move'
} catch {
/* ignore */
}
setDropSectionBand({ slot: sectionToSlot, phaseBelowSplitPo: Number(po) || 0 })
}
const applyParsedSectionDrop = (data) => {
const phaseRunMove = data.phaseRunMove
const fromSi = data.fromSectionIdx
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) {
return { kind: 'phaseRun', phaseRunMove, fromSlot }
}
if (typeof fromSi !== 'number') return null
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
fromSlot >= 0
) {
return { kind: 'crossSlot', fromSi, fromSlot }
}
return { kind: 'local', fromSi }
}
const onPhaseAboveSplitDrop = (e, po) => {
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
e.preventDefault()
e.stopPropagation()
clearSectionDnD()
let raw = ''
try {
raw = e.dataTransfer.getData(DND_TU_SECTION)
} catch {
return
}
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
const targetPo = Number(po) || 0
const parsed = applyParsedSectionDrop(data)
if (!parsed) return
if (parsed.kind === 'phaseRun') {
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
if (dragPo === targetPo) return
patch((prev) => {
const idxs = indicesOfParallelPhase(prev, targetPo)
const fg = idxs.length ? idxs[0] : -1
if (fg < 0) return prev
let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
return
}
if (parsed.kind === 'crossSlot') return
const { fromSi } = parsed
patch((prev) => {
let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, fromSi, targetPo)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
}
const onPhaseBelowSplitDrop = (e, po) => {
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
e.preventDefault()
e.stopPropagation()
clearSectionDnD()
let raw = ''
try {
raw = e.dataTransfer.getData(DND_TU_SECTION)
} catch {
return
}
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
const targetPo = Number(po) || 0
const parsed = applyParsedSectionDrop(data)
if (!parsed) return
if (parsed.kind === 'phaseRun') {
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
if (dragPo === targetPo) return
patch((prev) => {
const idxs = indicesOfParallelPhase(prev, targetPo)
const fg = idxs.length ? idxs[0] : -1
if (fg < 0) return prev
let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
return
}
if (parsed.kind === 'crossSlot') return
const { fromSi } = parsed
patch((prev) => {
let next = reorderSectionAsFirstInParallelPhase(prev, fromSi, targetPo)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
}
const onSectionBandDrop = (e, insertBeforeIdx) => {
if (!enableSectionDragReorder) return
e.preventDefault()
@ -763,6 +904,32 @@ export default function TrainingUnitSectionsEditor({
if (typeof fromSi !== 'number') return
if (enableParallelPhaseControls) {
const fromPl = list[fromSi]?.planLoc
if (fromPl?.phaseKind === 'parallel' && insertBeforeIdx >= 0 && insertBeforeIdx < list.length) {
const po = fromPl.phaseOrderIndex ?? 0
const fromSo = fromPl.parallelStreamOrderIndex ?? 0
const tabSo =
parallelStreamTabByPhase[po] ?? streamsForParallelPhaseOrders(list, po)[0] ?? 0
const targetPl = list[insertBeforeIdx]?.planLoc
if (
targetPl?.phaseKind === 'parallel' &&
(targetPl.phaseOrderIndex ?? 0) === po &&
(targetPl.parallelStreamOrderIndex ?? 0) !== fromSo &&
tabSo === fromSo
) {
return
}
}
}
if (
enableParallelPhaseControls &&
(insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1)
) {
return
}
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
@ -1182,12 +1349,6 @@ export default function TrainingUnitSectionsEditor({
parallelPhaseOrder != null
? firstSectionIndexByParallelPhase.get(parallelPhaseOrder)
: null
const phaseIndicesAll =
parallelPhaseOrder != null ? indicesOfParallelPhase(list, parallelPhaseOrder) : []
const isLastGlobalRowOfParallelPhase =
pl?.phaseKind === 'parallel' &&
phaseIndicesAll.length > 0 &&
sIdx === phaseIndicesAll[phaseIndicesAll.length - 1]
const firstVisibleIdxActiveStream =
parallelPhaseOrder != null && streamOrdersForParallelPhase.length
? sectionIndicesForParallelStream(
@ -1203,18 +1364,26 @@ export default function TrainingUnitSectionsEditor({
firstGlobalIdxThisPhase != null &&
sIdx !== firstGlobalIdxThisPhase
const isFirstSectionOfParallelPhase =
parallelPhaseOrder != null &&
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
const hideDropBeforeFirstParallelBecauseDedicatedSlot =
enableParallelPhaseControls &&
isFirstSectionOfParallelPhase &&
pl?.phaseKind === 'parallel'
const showSectionDropBandBefore =
(pl?.phaseKind !== 'parallel' ||
!hideParallelSection ||
isLastGlobalRowOfParallelPhase) &&
!hideDropBandBeforeOrphanFirstVisible
(pl?.phaseKind !== 'parallel' || !hideParallelSection) &&
!hideDropBandBeforeOrphanFirstVisible &&
!hideDropBeforeFirstParallelBecauseDedicatedSlot
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx &&
!dropSectionBand.streamDrop
!dropSectionBand.streamDrop &&
dropSectionBand.phaseAboveSplitPo == null &&
dropSectionBand.phaseBelowSplitPo == null
const streamChipDropActive = (po, so) =>
useStreamTagDropUx &&
@ -1222,9 +1391,15 @@ export default function TrainingUnitSectionsEditor({
dropSectionBand?.streamDrop?.po === po &&
dropSectionBand?.streamDrop?.so === so
const isFirstSectionOfParallelPhase =
const phaseAboveSplitDnd =
parallelPhaseOrder != null &&
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
dropSectionBand?.slot === sectionToSlot &&
dropSectionBand?.phaseAboveSplitPo === parallelPhaseOrder
const phaseBelowSplitDnd =
parallelPhaseOrder != null &&
dropSectionBand?.slot === sectionToSlot &&
dropSectionBand?.phaseBelowSplitPo === parallelPhaseOrder
const streamVisual =
enableParallelPhaseControls && pl?.phaseKind === 'parallel'
? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
@ -1256,6 +1431,28 @@ export default function TrainingUnitSectionsEditor({
{isFirstSectionOfParallelPhase &&
enableParallelPhaseControls &&
streamOrdersForParallelPhase.length ? (
<Fragment key={`phase-edge-${parallelPhaseOrder}`}>
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-phase-drop--above-split tu-section-dropband--phase-parallel-slot' +
sectionDropBandRegionClass(
list,
firstGlobalIdxThisPhase ?? sIdx,
enableParallelPhaseControls
) +
(phaseAboveSplitDnd ? ' tu-section-dropband--active' : '')
}
title="Oberhalb der Split-Phase: Abschnitt in die Ganzgruppe ziehen"
aria-label="Dropzone Ganzgruppe oberhalb der Split-Phase"
onDragOver={(e) => onPhaseAboveSplitDragOver(e, parallelPhaseOrder)}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)}
/>
) : null}
<div
style={{
marginBottom: '12px',
@ -1425,6 +1622,22 @@ export default function TrainingUnitSectionsEditor({
+ Abschnitt in diesem Stream
</button>
</div>
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-phase-drop--below-split tu-section-dropband--phase-parallel-slot' +
(phaseBelowSplitDnd ? ' tu-section-dropband--active' : '')
}
title="Erster Abschnitt dieser Split-Phase (vorne einfügen)"
aria-label="Dropzone erster Abschnitt unter dem Split-Kopf"
onDragOver={(e) => onPhaseBelowSplitDragOver(e, parallelPhaseOrder)}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onPhaseBelowSplitDrop(e, parallelPhaseOrder)}
/>
) : null}
<div
role="tablist"
aria-label={`Streams · Phase ${parallelPhaseOrder}`}
@ -1584,6 +1797,7 @@ export default function TrainingUnitSectionsEditor({
})}
</div>
</div>
</Fragment>
) : null}
{!hideParallelSection ? (
<>

View File

@ -863,6 +863,48 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
return arr
}
/**
* Abschnitt direkt vor den Parallel-Lauf setzen (immer Ganzgruppe oberhalb der Split-Phase).
*/
export function reorderSectionBeforeParallelRunAsWholeGroup(prev, fromI, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const idxs = indicesOfParallelPhase(prev, po)
if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev
const fg = idxs[0]
const arr = [...prev]
const [moved] = arr.splice(fromI, 1)
let insertAt = fg
if (fromI < insertAt) insertAt -= 1
insertAt = Math.max(0, Math.min(insertAt, arr.length))
const above = insertAt > 0 ? arr[insertAt - 1] : undefined
let planLocNext
if (above?.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
} else if (!above) {
planLocNext = defaultPlanLocWholeGroup(0)
} else {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
}
arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext })
return arr
}
/** Abschnitt als neuen ersten Eintrag der Parallel-Phase (gleiche Phasen-/Stream-Metadaten wie bisheriger Kopf). */
export function reorderSectionAsFirstInParallelPhase(prev, fromI, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const idxs = indicesOfParallelPhase(prev, po)
if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev
let fg = idxs[0]
const arr = [...prev]
const headTpl = { ...arr[fg].planLoc }
const [moved] = arr.splice(fromI, 1)
if (fromI < fg) fg -= 1
fg = Math.max(0, Math.min(fg, arr.length))
arr.splice(fg, 0, { ...moved, planLoc: { ...headTpl } })
return arr
}
/**
* Abschnitt ans Ende eines parallelen Streams setzen (planLoc wie dieser Stream).
* Leerer Stream: Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase, planLoc vom Referenz-Abschnitt mit angepasstem streamIndex.