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);
|
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 {
|
.tu-sec-drag-grip {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
phaseRunsFromSections,
|
phaseRunsFromSections,
|
||||||
swapAdjacentPhaseRuns,
|
swapAdjacentPhaseRuns,
|
||||||
reorderBlocksImmutableWithPlanLoc,
|
reorderBlocksImmutableWithPlanLoc,
|
||||||
|
reorderBlockIntoParallelStreamEnd,
|
||||||
|
globalInsertBeforeIndexForParallelStreamEnd,
|
||||||
movePhaseRunUpByPhaseOrder,
|
movePhaseRunUpByPhaseOrder,
|
||||||
movePhaseRunDownByPhaseOrder,
|
movePhaseRunDownByPhaseOrder,
|
||||||
moveParallelPhaseRunToInsertBefore,
|
moveParallelPhaseRunToInsertBefore,
|
||||||
|
|
@ -281,6 +283,12 @@ export default function TrainingUnitSectionsEditor({
|
||||||
const sectionToSlot =
|
const sectionToSlot =
|
||||||
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
|
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) => {
|
const updateSectionField = (sIdx, field, val) => {
|
||||||
patch((prev) =>
|
patch((prev) =>
|
||||||
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
|
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) => {
|
const onItemDragStart = (e, sIdx, iIdx) => {
|
||||||
if (!enableItemDragReorder) return
|
if (!enableItemDragReorder) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -912,8 +992,6 @@ export default function TrainingUnitSectionsEditor({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = ensure(sections)
|
|
||||||
|
|
||||||
const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list])
|
const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list])
|
||||||
|
|
||||||
const firstSectionIndexByParallelPhase = useMemo(() => {
|
const firstSectionIndexByParallelPhase = useMemo(() => {
|
||||||
|
|
@ -1082,12 +1160,6 @@ export default function TrainingUnitSectionsEditor({
|
||||||
const planMin = sectionPlannedMinutes(sec)
|
const planMin = sectionPlannedMinutes(sec)
|
||||||
const itemCount = sec.items?.length ?? 0
|
const itemCount = sec.items?.length ?? 0
|
||||||
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
|
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
|
||||||
const bandActiveBefore = (bx) =>
|
|
||||||
enableSectionDragReorder &&
|
|
||||||
dropSectionBand &&
|
|
||||||
dropSectionBand.slot === sectionToSlot &&
|
|
||||||
dropSectionBand.beforeIdx === bx
|
|
||||||
|
|
||||||
const pl = sec?.planLoc
|
const pl = sec?.planLoc
|
||||||
const parallelPhaseOrder =
|
const parallelPhaseOrder =
|
||||||
enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null
|
enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null
|
||||||
|
|
@ -1101,6 +1173,22 @@ export default function TrainingUnitSectionsEditor({
|
||||||
enableParallelPhaseControls &&
|
enableParallelPhaseControls &&
|
||||||
pl?.phaseKind === 'parallel' &&
|
pl?.phaseKind === 'parallel' &&
|
||||||
(pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream
|
(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 =
|
const isFirstSectionOfParallelPhase =
|
||||||
parallelPhaseOrder != null &&
|
parallelPhaseOrder != null &&
|
||||||
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
|
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
|
||||||
|
|
@ -1112,7 +1200,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={`secFrag-${sIdx}`}>
|
<Fragment key={`secFrag-${sIdx}`}>
|
||||||
{enableSectionDragReorder ? (
|
{enableSectionDragReorder && showSectionDropBandBefore ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'tu-section-dropband' +
|
'tu-section-dropband' +
|
||||||
|
|
@ -1323,6 +1411,12 @@ export default function TrainingUnitSectionsEditor({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`p${parallelPhaseOrder}-chip-s${so}`}
|
key={`p${parallelPhaseOrder}-chip-s${so}`}
|
||||||
|
className={
|
||||||
|
'tu-stream-chip-pill' +
|
||||||
|
(streamChipDropActive(parallelPhaseOrder, so)
|
||||||
|
? ' tu-stream-chip-pill--drop-active'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -1333,6 +1427,24 @@ export default function TrainingUnitSectionsEditor({
|
||||||
background: sel ? pv.tabBgActive : pv.tabBg,
|
background: sel ? pv.tabBgActive : pv.tabBg,
|
||||||
maxWidth: '100%',
|
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 ? (
|
{editingStream ? (
|
||||||
<input
|
<input
|
||||||
|
|
@ -1441,6 +1553,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!hideParallelSection ? (
|
{!hideParallelSection ? (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className="tu-section-shell"
|
className="tu-section-shell"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -2096,6 +2209,55 @@ export default function TrainingUnitSectionsEditor({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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}
|
) : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
|
|
@ -2105,14 +2267,22 @@ export default function TrainingUnitSectionsEditor({
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'tu-section-dropband tu-section-dropband--end' +
|
'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 &&
|
||||||
dropSectionBand.slot === sectionToSlot &&
|
dropSectionBand.slot === sectionToSlot &&
|
||||||
dropSectionBand.beforeIdx === list.length
|
dropSectionBand.beforeIdx === list.length &&
|
||||||
|
!dropSectionBand.streamDrop
|
||||||
? ' tu-section-dropband--active'
|
? ' 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) => {
|
onDragOver={(e) => {
|
||||||
if (!dtHasType(e, DND_TU_SECTION)) return
|
if (!dtHasType(e, DND_TU_SECTION)) return
|
||||||
onSectionBandDragOver(e, list.length)
|
onSectionBandDragOver(e, list.length)
|
||||||
|
|
|
||||||
|
|
@ -842,6 +842,57 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
|
||||||
return arr
|
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. */
|
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
|
||||||
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
|
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
|
||||||
const po = Number(phaseOrderIndex) || 0
|
const po = Number(phaseOrderIndex) || 0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user