feat: enable drag reorder for skeleton requirements

This commit is contained in:
Matthieu
2025-10-23 09:36:46 +02:00
parent 417b34b45e
commit 325bdb3d6f
4 changed files with 299 additions and 26 deletions

View File

@@ -138,8 +138,22 @@
<div
v-for="(piece, index) in node.pieces"
:key="`piece-${index}`"
class="border border-base-200 rounded-md p-3 space-y-3"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="pieceReorderClass(index)"
@dragenter="onPieceDragEnter(index)"
@dragover="onPieceDragOver"
@drop="onPieceDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onPieceDragStart(index, $event)"
@dragend="onPieceDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
@@ -195,17 +209,35 @@
Aucun sous-composant défini.
</p>
<div v-else class="space-y-3">
<StructureNodeEditor
<div
v-for="(subComponent, index) in node.subcomponents"
:key="`sub-${index}`"
:node="subComponent"
:depth="depth + 1"
:component-types="componentTypes"
:piece-types="pieceTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)"
/>
class="relative pl-8 transition-shadow rounded-lg"
:class="subcomponentReorderClass(index)"
@dragenter="onSubcomponentDragEnter(index)"
@dragover="onSubcomponentDragOver"
@drop="onSubcomponentDrop(index)"
>
<button
type="button"
class="absolute left-0 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onSubcomponentDragStart(index, $event)"
@dragend="onSubcomponentDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<StructureNodeEditor
:node="subComponent"
:depth="depth + 1"
:component-types="componentTypes"
:piece-types="pieceTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)"
/>
</div>
</div>
</section>
</div>
@@ -214,9 +246,10 @@
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import type { ComponentModelPiece, ComponentModelStructureNode } from '~/shared/types/inventory'
defineOptions({ name: 'StructureNodeEditor' })
@@ -525,6 +558,138 @@ const removeSubComponent = (index: number) => {
props.node.subcomponents.splice(index, 1)
}
const draggingPieceIndex = ref<number | null>(null)
const pieceDropTargetIndex = ref<number | null>(null)
const draggingSubcomponentIndex = ref<number | null>(null)
const subcomponentDropTargetIndex = ref<number | null>(null)
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
if (from === to) {
return
}
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
return
}
const updated = list.slice()
const [item] = updated.splice(from, 1)
updated.splice(to, 0, item)
list.splice(0, list.length, ...updated)
}
const resetPieceDragState = () => {
draggingPieceIndex.value = null
pieceDropTargetIndex.value = null
}
const onPieceDragStart = (index: number, event: DragEvent) => {
draggingPieceIndex.value = index
pieceDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onPieceDragEnter = (index: number) => {
if (draggingPieceIndex.value === null) {
return
}
pieceDropTargetIndex.value = index
}
const onPieceDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onPieceDrop = (index: number) => {
if (!Array.isArray(props.node.pieces)) {
resetPieceDragState()
return
}
const from = draggingPieceIndex.value
const to = index
if (from === null || to === null) {
resetPieceDragState()
return
}
moveItemInPlace(props.node.pieces, from, to)
resetPieceDragState()
}
const onPieceDragEnd = () => {
resetPieceDragState()
}
const pieceReorderClass = (index: number) => {
if (draggingPieceIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingPieceIndex.value !== null &&
pieceDropTargetIndex.value === index &&
draggingPieceIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const resetSubcomponentDragState = () => {
draggingSubcomponentIndex.value = null
subcomponentDropTargetIndex.value = null
}
const onSubcomponentDragStart = (index: number, event: DragEvent) => {
draggingSubcomponentIndex.value = index
subcomponentDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onSubcomponentDragEnter = (index: number) => {
if (draggingSubcomponentIndex.value === null) {
return
}
subcomponentDropTargetIndex.value = index
}
const onSubcomponentDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onSubcomponentDrop = (index: number) => {
if (!Array.isArray(props.node.subcomponents)) {
resetSubcomponentDragState()
return
}
const from = draggingSubcomponentIndex.value
const to = index
if (from === null || to === null) {
resetSubcomponentDragState()
return
}
moveItemInPlace(props.node.subcomponents, from, to)
resetSubcomponentDragState()
}
const onSubcomponentDragEnd = () => {
resetSubcomponentDragState()
}
const subcomponentReorderClass = (index: number) => {
if (draggingSubcomponentIndex.value === index) {
return 'ring-2 ring-primary'
}
if (
draggingSubcomponentIndex.value !== null &&
subcomponentDropTargetIndex.value === index &&
draggingSubcomponentIndex.value !== index
) {
return 'ring-2 ring-primary/70'
}
return ''
}
watch(
canManageSubcomponents,
(allowed) => {

View File

@@ -20,8 +20,22 @@
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="border border-base-200 rounded-lg p-4 space-y-3"
class="relative border border-base-200 rounded-lg p-4 pl-12 space-y-3 transition-colors"
:class="requirementReorderClass(index)"
@dragenter="onRequirementDragEnter(index)"
@dragover="onRequirementDragOver"
@drop="onRequirementDrop(index)"
>
<button
type="button"
class="absolute left-3 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onRequirementDragStart(index, $event)"
@dragend="onRequirementDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
@@ -120,11 +134,12 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { PropType } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
type Option = {
id: string | number
@@ -139,6 +154,7 @@ type Requirement = Record<string, unknown> & {
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
orderIndex?: number | null
}
type Labels = {
@@ -221,20 +237,30 @@ const optionDescription = (option: Option) => {
return ''
}
const applyOrderIndex = (list: Requirement[]): Requirement[] =>
list.map((item, index) => ({
...item,
orderIndex: index,
}))
const addRequirement = () => {
requirements.value = [
requirements.value = applyOrderIndex([
...requirements.value,
props.defaultRequirement(),
]
])
}
const removeRequirement = (index: number) => {
requirements.value = requirements.value.filter((_, i) => i !== index)
requirements.value = applyOrderIndex(
requirements.value.filter((_, i) => i !== index),
)
}
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
requirements.value = requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item
requirements.value = applyOrderIndex(
requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item,
),
)
}
@@ -251,6 +277,76 @@ const parseOptionalNumber = (value: string) => {
return Number.isFinite(parsed) ? parsed : null
}
const draggingRequirementIndex = ref<number | null>(null)
const requirementDropTargetIndex = ref<number | null>(null)
const resetRequirementDragState = () => {
draggingRequirementIndex.value = null
requirementDropTargetIndex.value = null
}
const reorderRequirements = (from: number, to: number) => {
const list = requirements.value
if (!Array.isArray(list)) {
resetRequirementDragState()
return
}
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetRequirementDragState()
return
}
const updated = list.slice() as Requirement[]
const [moved] = updated.splice(from, 1)
updated.splice(to, 0, moved)
requirements.value = applyOrderIndex(updated)
resetRequirementDragState()
}
const onRequirementDragStart = (index: number, event: DragEvent) => {
draggingRequirementIndex.value = index
requirementDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onRequirementDragEnter = (index: number) => {
if (draggingRequirementIndex.value === null) {
return
}
requirementDropTargetIndex.value = index
}
const onRequirementDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onRequirementDrop = (index: number) => {
if (draggingRequirementIndex.value === null) {
resetRequirementDragState()
return
}
reorderRequirements(draggingRequirementIndex.value, index)
}
const onRequirementDragEnd = () => {
resetRequirementDragState()
}
const requirementReorderClass = (index: number) => {
if (draggingRequirementIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingRequirementIndex.value !== null &&
requirementDropTargetIndex.value === index &&
draggingRequirementIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const normalizeTypeModel = (value: unknown) => {
if (value === null || value === undefined) {
return ''

View File

@@ -157,26 +157,32 @@ const toIntegerOrNull = (value, fallback = null) => {
const normalizeComponentRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeComposantId)
.map(req => ({
.map((req, index) => ({
typeComposantId: req.typeComposantId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? true,
allowNewModels: req.allowNewModels ?? true
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizePieceRequirements = (requirements = []) =>
requirements
.filter(req => req?.typePieceId)
.map(req => ({
.map((req, index) => ({
typePieceId: req.typePieceId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const buildPayload = typeData => ({
name: typeData.name,

View File

@@ -110,26 +110,32 @@ const toIntegerOrNull = (value, fallback = null) => {
const normalizeComponentRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeComposantId)
.map(req => ({
.map((req, index) => ({
typeComposantId: req.typeComposantId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? true,
allowNewModels: req.allowNewModels ?? true
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizePieceRequirements = (requirements = []) =>
requirements
.filter(req => req?.typePieceId)
.map(req => ({
.map((req, index) => ({
typePieceId: req.typePieceId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const saveChanges = async () => {
try {