feat: enable drag reorder for skeleton requirements
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user