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
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:
parent
514b64682c
commit
c2efbee4ee
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user