Implement drag-and-drop functionality for parallel stream sections in TrainingUnitSectionsEditor
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 1s
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 CSS styles for visual feedback during drag-and-drop operations, enhancing user experience.
- Introduced logic to manage section drops into parallel streams, allowing for intuitive reordering of sections.
- Implemented utility functions to facilitate the movement of sections within parallel streams, ensuring proper plan location adjustments.
- Updated the TrainingUnitSectionsEditor component to utilize the new drag-and-drop features, improving overall functionality and usability.
This commit is contained in:
Lars 2026-05-15 09:10:54 +02:00
parent 514b64682c
commit c2efbee4ee
3 changed files with 249 additions and 12 deletions

View File

@ -5974,6 +5974,22 @@ a.analysis-split__nav-item {
background: color-mix(in srgb, var(--surface2) 55%, transparent);
}
/* Stream-Tag / Planende: klar getrennte Drop-Ziele bei parallelen Phasen */
.tu-stream-chip-pill--drop-active {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
}
.tu-section-dropband--whole-plan-end {
background: color-mix(in srgb, var(--accent) 14%, var(--surface2));
border: 1px dashed color-mix(in srgb, var(--accent) 35%, transparent);
height: 14px;
}
.tu-section-stream-append__drop {
margin: 0;
width: 100%;
}
.tu-sec-drag-grip {
flex-shrink: 0;
display: inline-flex;

View File

@ -24,6 +24,8 @@ import {
phaseRunsFromSections,
swapAdjacentPhaseRuns,
reorderBlocksImmutableWithPlanLoc,
reorderBlockIntoParallelStreamEnd,
globalInsertBeforeIndexForParallelStreamEnd,
movePhaseRunUpByPhaseOrder,
movePhaseRunDownByPhaseOrder,
moveParallelPhaseRunToInsertBefore,
@ -281,6 +283,12 @@ export default function TrainingUnitSectionsEditor({
const sectionToSlot =
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
const list = ensure(sections)
const useStreamTagDropUx =
enableSectionDragReorder &&
enableParallelPhaseControls &&
list.some((s) => s?.planLoc?.phaseKind === 'parallel')
const updateSectionField = (sIdx, field, val) => {
patch((prev) =>
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
@ -774,6 +782,78 @@ export default function TrainingUnitSectionsEditor({
})
}
const onStreamDropTargetDragOver = (e, phaseOrder, streamOrder) => {
if (!enableSectionDragReorder || !useStreamTagDropUx) return
if (!dtHasType(e, DND_TU_SECTION)) return
e.preventDefault()
e.stopPropagation()
try {
e.dataTransfer.dropEffect = 'move'
} catch {
/* ignore */
}
setDropSectionBand({
slot: sectionToSlot,
streamDrop: { po: Number(phaseOrder) || 0, so: Number(streamOrder) || 0 },
})
}
const onStreamDropTargetDragLeave = (e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}
const onStreamDropTargetDrop = (e, phaseOrder, streamOrder) => {
if (!enableSectionDragReorder || !useStreamTagDropUx) 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
}
if (data.phaseRunMove != null && data.phaseRunMove.phaseOrderIndex != null) {
return
}
const fromSi = data.fromSectionIdx
if (typeof fromSi !== 'number') return
const po = Number(phaseOrder) || 0
const so = Number(streamOrder) || 0
const toIdx = globalInsertBeforeIndexForParallelStreamEnd(list, po, so)
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
fromSlot >= 0
) {
onMoveSectionsAcrossSlots({
fromSlot,
fromSectionIdx: fromSi,
toSlot: sectionToSlot,
toSectionIdx: toIdx,
})
return
}
patch((prev) => {
let next = reorderBlockIntoParallelStreamEnd(prev, fromSi, po, so)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
}
const onItemDragStart = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return
e.stopPropagation()
@ -912,8 +992,6 @@ export default function TrainingUnitSectionsEditor({
)
}
const list = ensure(sections)
const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list])
const firstSectionIndexByParallelPhase = useMemo(() => {
@ -1082,12 +1160,6 @@ export default function TrainingUnitSectionsEditor({
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx
const pl = sec?.planLoc
const parallelPhaseOrder =
enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null
@ -1101,6 +1173,22 @@ export default function TrainingUnitSectionsEditor({
enableParallelPhaseControls &&
pl?.phaseKind === 'parallel' &&
(pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream
const showSectionDropBandBefore =
pl?.phaseKind !== 'parallel' || !hideParallelSection
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx &&
!dropSectionBand.streamDrop
const streamChipDropActive = (po, so) =>
useStreamTagDropUx &&
dropSectionBand?.slot === sectionToSlot &&
dropSectionBand?.streamDrop?.po === po &&
dropSectionBand?.streamDrop?.so === so
const isFirstSectionOfParallelPhase =
parallelPhaseOrder != null &&
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
@ -1112,7 +1200,7 @@ export default function TrainingUnitSectionsEditor({
return (
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? (
{enableSectionDragReorder && showSectionDropBandBefore ? (
<div
className={
'tu-section-dropband' +
@ -1323,6 +1411,12 @@ export default function TrainingUnitSectionsEditor({
return (
<div
key={`p${parallelPhaseOrder}-chip-s${so}`}
className={
'tu-stream-chip-pill' +
(streamChipDropActive(parallelPhaseOrder, so)
? ' tu-stream-chip-pill--drop-active'
: '')
}
style={{
display: 'inline-flex',
alignItems: 'center',
@ -1333,6 +1427,24 @@ export default function TrainingUnitSectionsEditor({
background: sel ? pv.tabBgActive : pv.tabBg,
maxWidth: '100%',
}}
title={
useStreamTagDropUx
? 'Gruppe wählen oder Abschnitt hierher ziehen'
: undefined
}
onDragOver={
useStreamTagDropUx
? (e) => onStreamDropTargetDragOver(e, parallelPhaseOrder, so)
: undefined
}
onDragLeave={
useStreamTagDropUx ? onStreamDropTargetDragLeave : undefined
}
onDrop={
useStreamTagDropUx
? (e) => onStreamDropTargetDrop(e, parallelPhaseOrder, so)
: undefined
}
>
{editingStream ? (
<input
@ -1441,6 +1553,7 @@ export default function TrainingUnitSectionsEditor({
</div>
) : null}
{!hideParallelSection ? (
<>
<div
className="tu-section-shell"
style={{
@ -2096,6 +2209,55 @@ export default function TrainingUnitSectionsEditor({
</div>
) : null}
</div>
{useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null
? (() => {
const bucket = sectionIndicesForParallelStream(
list,
parallelPhaseOrder,
pl.parallelStreamOrderIndex ?? 0
)
if (!bucket.length || bucket[bucket.length - 1] !== sIdx) return null
const pvA = parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
const soA = pl.parallelStreamOrderIndex ?? 0
const sd = dropSectionBand?.streamDrop
const appendBandActive =
!!sd &&
dropSectionBand?.slot === sectionToSlot &&
sd.po === parallelPhaseOrder &&
sd.so === soA
return (
<div
className="tu-section-stream-append"
style={{
marginBottom: '1rem',
padding: '0.45rem 0.65rem',
borderRadius: '10px',
border: `1px dashed ${pvA.border}`,
background: pvA.soft,
borderLeft: `5px solid ${pvA.border}`,
boxSizing: 'border-box',
}}
>
<div
className={
'tu-section-stream-append__drop tu-section-dropband tu-section-dropband--region-split' +
(appendBandActive ? ' tu-section-dropband--active' : '')
}
title="Abschnitt am Ende dieser Gruppe einfügen (per Ziehen)"
aria-label="Dropzone: Abschnitt ans Ende dieser parallelen Gruppe"
onDragOver={(e) =>
onStreamDropTargetDragOver(e, parallelPhaseOrder, soA)
}
onDragLeave={onStreamDropTargetDragLeave}
onDrop={(e) =>
onStreamDropTargetDrop(e, parallelPhaseOrder, soA)
}
/>
</div>
)
})()
: null}
</>
) : null}
</Fragment>
)
@ -2105,14 +2267,22 @@ export default function TrainingUnitSectionsEditor({
<div
className={
'tu-section-dropband tu-section-dropband--end' +
sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls) +
(useStreamTagDropUx ? ' tu-section-dropband--whole-plan-end' : '') +
(!useStreamTagDropUx
? sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls)
: '') +
(dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === list.length
dropSectionBand.beforeIdx === list.length &&
!dropSectionBand.streamDrop
? ' tu-section-dropband--active'
: '')
}
title="Abschnitt am Ende einfügen"
title={
useStreamTagDropUx
? 'Hier ablegen: neuer Abschnitt für die Ganzgruppe (am Planende)'
: 'Abschnitt am Ende einfügen'
}
onDragOver={(e) => {
if (!dtHasType(e, DND_TU_SECTION)) return
onSectionBandDragOver(e, list.length)

View File

@ -842,6 +842,57 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
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.
*/
export function reorderBlockIntoParallelStreamEnd(prev, fromI, phaseOrderIndex, streamOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
const len = prev?.length ?? 0
if (fromI < 0 || fromI >= len) return prev
const arr = [...prev]
const [moved] = arr.splice(fromI, 1)
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
let insertAt
let planLocTemplate
if (streamIdx.length) {
const last = Math.max(...streamIdx)
planLocTemplate = { ...arr[last].planLoc }
insertAt = last + 1
} else {
const phaseIdx = indicesOfParallelPhase(arr, po)
if (!phaseIdx.length) return prev
const ref = arr[phaseIdx[phaseIdx.length - 1]]
planLocTemplate = {
...ref.planLoc,
parallelStreamOrderIndex: so,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
insertAt = phaseIdx[phaseIdx.length - 1] + 1
}
if (fromI < insertAt) insertAt -= 1
insertAt = Math.max(0, Math.min(insertAt, arr.length))
arr.splice(insertAt, 0, { ...moved, planLoc: { ...planLocTemplate } })
return arr
}
/** Globales insertBeforeIndex, um einen Abschnitt hinter den letzten des Streams einzufügen (z. B. Rahmen-Slots). */
export function globalInsertBeforeIndexForParallelStreamEnd(sections, phaseOrderIndex, streamOrderIndex) {
const arr = sections || []
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
if (streamIdx.length) return Math.max(...streamIdx) + 1
const phaseIdx = indicesOfParallelPhase(arr, po)
if (!phaseIdx.length) return arr.length
return Math.max(...phaseIdx) + 1
}
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0