feat: drag & drop des champs personnalisés

This commit is contained in:
Matthieu
2025-10-28 18:08:14 +01:00
parent b752fba69a
commit 4c714b3647
16 changed files with 458 additions and 67 deletions

View File

@@ -461,16 +461,39 @@ const extractStructureCustomFields = (structure) => {
}
function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name.trim() : ''
const normalizedType = typeof type === 'string' ? type : ''
const normalizedName =
typeof name === 'string' ? name.trim().toLowerCase() : ''
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : ''
return normalizedName ? `${normalizedName}::${normalizedType}` : null
}
function resolveOrderIndex(field) {
if (!field || typeof field !== 'object') {
return 0
}
if (typeof field.orderIndex === 'number') {
return field.orderIndex
}
if (
field.customField &&
typeof field.customField.orderIndex === 'number'
) {
return field.customField.orderIndex
}
return 0
}
function deduplicateFieldDefinitions(definitions) {
const result = []
const seen = new Set()
;(Array.isArray(definitions) ? definitions : []).forEach((field) => {
const orderedDefinitions = (Array.isArray(definitions)
? definitions.slice()
: []
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
@@ -490,6 +513,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) {
seen.add(key)
}
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
@@ -530,10 +554,16 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
if (!matchedValue) {
return {
...field,
value: field?.value ?? ''
value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
}
}
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
)
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -543,7 +573,8 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
fieldId ??
null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? ''
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
}
})
@@ -583,23 +614,30 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
required: entry.customField?.required ?? false,
options: entry.customField?.options ?? [],
value: entry.value ?? '',
customField: entry.customField ?? null
customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
})
}
})
return merged
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields) ? fields : []
return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: []
}
const seen = new Map()
const result = []
fields.forEach((field) => {
const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
@@ -617,12 +655,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey
if (!key) {
field.orderIndex = resolveOrderIndex(field)
result.push(field)
return
}
const existing = seen.get(key)
if (!existing) {
field.orderIndex = resolveOrderIndex(field)
seen.set(key, field)
result.push(field)
return
@@ -640,11 +680,15 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field)
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
)
seen.set(key, existing)
}
})
return result
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
const componentDefinitionSources = computed(() => {

View File

@@ -5,7 +5,7 @@
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in customFields"
v-for="field in sortedCustomFields"
:key="field.id"
class="form-control"
>
@@ -81,7 +81,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({
customFields: {
@@ -101,6 +101,17 @@ const props = defineProps({
const emit = defineEmits(['update'])
const sortedCustomFields = computed(() => {
if (!Array.isArray(props.customFields)) {
return []
}
return [...props.customFields].sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
})
// Valeurs des champs personnalisés
const fieldValues = reactive({})

View File

@@ -456,16 +456,36 @@ const extractStructureCustomFields = (structure) => {
return Array.isArray(customFields) ? customFields : [];
};
function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : '';
const normalizedType = typeof type === 'string' ? type : '';
const normalizedName =
typeof name === 'string' ? name.trim().toLowerCase() : '';
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : '';
return normalizedName ? `${normalizedName}::${normalizedType}` : null;
}
function resolveOrderIndex(field) {
if (!field || typeof field !== 'object') {
return 0;
}
if (typeof field.orderIndex === 'number') {
return field.orderIndex;
}
if (field.customField && typeof field.customField.orderIndex === 'number') {
return field.customField.orderIndex;
}
return 0;
}
function deduplicateFieldDefinitions(definitions) {
const result = [];
const seen = new Set();
(Array.isArray(definitions) ? definitions : []).forEach((field) => {
const orderedDefinitions = (Array.isArray(definitions)
? definitions.slice()
: []
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') {
return;
}
@@ -482,6 +502,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) {
seen.add(key);
}
field.orderIndex = resolveOrderIndex(field);
result.push(field);
});
@@ -529,9 +550,15 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
return {
...field,
value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
};
}
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
);
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -542,6 +569,7 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
};
});
@@ -588,22 +616,31 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
options: entry.customField?.options ?? [],
value: entry.value ?? '',
customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
});
}
});
return merged;
return merged.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
}
function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields) ? fields : [];
return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: [];
}
const seen = new Map();
const result = [];
fields.forEach((field) => {
const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') {
return;
}
@@ -632,12 +669,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey;
if (!key) {
field.orderIndex = resolveOrderIndex(field);
result.push(field);
return;
}
const existing = seen.get(key);
if (!existing) {
field.orderIndex = resolveOrderIndex(field);
seen.set(key, field);
result.push(field);
return;
@@ -654,11 +693,17 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field);
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
);
seen.set(key, existing);
}
});
return result;
return result.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
}
const pieceDefinitionSources = computed(() => {

View File

@@ -108,7 +108,7 @@ const extractRest = (structure = {}) => {
)
}
const toEditorField = (input = {}) => ({
const toEditorField = (input = {}, index = 0) => ({
name: typeof input.name === 'string' ? input.name : '',
type: typeof input.type === 'string' && input.type ? input.type : 'text',
required: Boolean(input.required),
@@ -116,10 +116,14 @@ const toEditorField = (input = {}) => ({
? input.options.join('\n')
: typeof input.optionsText === 'string'
? input.optionsText
: ''
: '',
orderIndex: typeof input.orderIndex === 'number' ? input.orderIndex : index,
})
const hydrateFields = (structure = {}) => ensureArray(structure.customFields).map(toEditorField)
const hydrateFields = (structure = {}) =>
ensureArray(structure.customFields)
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
const localState = reactive({
fields: hydrateFields(props.modelValue)
@@ -138,7 +142,7 @@ const localFields = computed({
const normalizeFields = (fields) => {
return ensureArray(fields)
.map((field) => {
.map((field, index) => {
const name = typeof field.name === 'string' ? field.name.trim() : ''
if (!name) {
return null
@@ -156,7 +160,7 @@ const normalizeFields = (fields) => {
options = parsed.length > 0 ? parsed : undefined
}
const normalized = { name, type, required }
const normalized = { name, type, required, orderIndex: index }
if (options) {
normalized.options = options
}
@@ -199,7 +203,8 @@ watch(
watch(localFields, emitUpdate, { deep: true })
const addField = () => {
localFields.value = [...localFields.value, toEditorField()]
const index = localFields.value.length
localFields.value = [...localFields.value, toEditorField({}, index)]
}
const removeField = (index) => {

View File

@@ -79,9 +79,27 @@
<div
v-for="(field, index) in node.customFields"
:key="`field-${index}`"
class="border border-base-200 rounded-md p-3 space-y-2"
class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
:class="customFieldReorderClass(index)"
draggable="true"
@dragstart="onCustomFieldDragStart(index, $event)"
@dragenter="onCustomFieldDragEnter(index)"
@dragover.prevent
@drop="onCustomFieldDrop(index)"
@dragend="onCustomFieldDragEnd"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="true"
@dragstart.stop="onCustomFieldDragStart(index, $event)"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
@@ -506,20 +524,97 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
piece.typePieceLabel = formatPieceTypeOption(option)
}
const customFieldDragState = ref({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const reindexCustomFields = () => {
if (!Array.isArray(props.node.customFields)) {
return
}
props.node.customFields.forEach((field: any, index: number) => {
if (!field || typeof field !== 'object') {
return
}
field.orderIndex = index
})
}
const resetCustomFieldDragState = () => {
customFieldDragState.value.draggingIndex = null
customFieldDragState.value.dropTargetIndex = null
}
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
customFieldDragState.value.draggingIndex = index
customFieldDragState.value.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onCustomFieldDragEnter = (index: number) => {
if (customFieldDragState.value.draggingIndex === null) {
return
}
customFieldDragState.value.dropTargetIndex = index
}
const onCustomFieldDrop = (index: number) => {
if (!Array.isArray(props.node.customFields)) {
resetCustomFieldDragState()
return
}
const from = customFieldDragState.value.draggingIndex
const to = index
if (from === null || to === null) {
resetCustomFieldDragState()
return
}
moveItemInPlace(props.node.customFields, from, to)
reindexCustomFields()
resetCustomFieldDragState()
}
const onCustomFieldDragEnd = () => {
resetCustomFieldDragState()
}
const customFieldReorderClass = (index: number) => {
if (customFieldDragState.value.draggingIndex === index) {
return 'border-dashed border-primary'
}
if (
customFieldDragState.value.draggingIndex !== null &&
customFieldDragState.value.dropTargetIndex === index &&
customFieldDragState.value.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const addCustomField = () => {
ensureArray('customFields')
const nextIndex = Array.isArray(props.node.customFields)
? props.node.customFields.length
: 0
props.node.customFields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
orderIndex: nextIndex,
})
reindexCustomFields()
}
const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1)
reindexCustomFields()
}
const addPiece = () => {
@@ -723,6 +818,22 @@ watch(
{ deep: true }
)
watch(
() => props.node.customFields,
(value) => {
if (!Array.isArray(value)) {
return
}
value.sort((a: any, b: any) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
reindexCustomFields()
},
{ deep: true }
)
watch(
() => [props.lockedTypeLabel, props.lockType],
() => {

View File

@@ -24,11 +24,26 @@
<div v-if="expanded" class="space-y-4">
<div
v-for="(field, fieldIndex) in fields"
:key="fieldIndex"
class="border border-gray-200 rounded-lg p-4 bg-gray-50"
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
:class="fieldReorderClass(fieldIndex)"
draggable="true"
@dragstart="onFieldDragStart(fieldIndex, $event)"
@dragenter="onFieldDragEnter(fieldIndex)"
@dragover.prevent
@drop="onFieldDrop(fieldIndex)"
@dragend="onFieldDragEnd"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
@@ -160,6 +175,7 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideListChecks from '~icons/lucide/list-checks'
import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
const props = defineProps({
modelValue: {
@@ -183,8 +199,57 @@ const fields = computed({
set: value => emit('update:modelValue', value)
})
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const expanded = ref(false)
const expandedFields = ref([])
const draggingFieldIndex = ref(null)
const fieldDropTargetIndex = ref(null)
const applyOrderIndex = (list = []) => {
if (!Array.isArray(list)) { return [] }
list.forEach((field, index) => {
if (field && typeof field === 'object') {
field.orderIndex = index
if (typeof field.__key !== 'string' || !field.__key) {
field.__key = createFieldKey()
}
}
})
return list
}
const createEmptyField = () => ({
name: '',
type: '',
required: false,
optionsText: '',
orderIndex: fields.value.length,
__key: createFieldKey()
})
const resetDragState = () => {
draggingFieldIndex.value = null
fieldDropTargetIndex.value = null
}
const reorderFields = (from, to) => {
const list = Array.isArray(fields.value) ? fields.value.slice() : []
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
if (Array.isArray(expandedFields.value)) {
const expandedCopy = expandedFields.value.slice()
const [expandedState] = expandedCopy.splice(from, 1)
expandedCopy.splice(to, 0, expandedState)
expandedFields.value = expandedCopy
}
fields.value = applyOrderIndex(list)
resetDragState()
}
watch(
() => props.expandAllTrigger,
@@ -223,26 +288,25 @@ const toggleField = (index) => {
}
const addField = () => {
fields.value = [
...fields.value,
{
name: '',
type: '',
required: false,
optionsText: ''
}
]
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next.push(createEmptyField())
fields.value = applyOrderIndex(next)
expandedFields.value.push(true)
expanded.value = true
}
const removeField = (index) => {
fields.value = fields.value.filter((_, i) => i !== index)
const next = Array.isArray(fields.value)
? fields.value.filter((_, i) => i !== index)
: []
fields.value = applyOrderIndex(next)
expandedFields.value.splice(index, 1)
}
const updateField = (index, patch) => {
fields.value = fields.value.map((field, i) => (i === index ? { ...field, ...patch } : field))
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next[index] = { ...next[index], ...patch }
fields.value = applyOrderIndex(next)
}
const updateOptions = (index, value) => {
@@ -250,4 +314,43 @@ const updateOptions = (index, value) => {
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
})
}
const onFieldDragStart = (index, event) => {
draggingFieldIndex.value = index
fieldDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onFieldDragEnter = (index) => {
if (draggingFieldIndex.value === null) { return }
fieldDropTargetIndex.value = index
}
const onFieldDrop = (index) => {
if (draggingFieldIndex.value === null) {
resetDragState()
return
}
reorderFields(draggingFieldIndex.value, index)
}
const onFieldDragEnd = () => {
resetDragState()
}
const fieldReorderClass = (index) => {
if (draggingFieldIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingFieldIndex.value !== null &&
fieldDropTargetIndex.value === index &&
draggingFieldIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
</script>

View File

@@ -54,14 +54,33 @@ const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = value => JSON.parse(JSON.stringify(value))
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const withNormalizedOrder = (items = []) => {
if (!Array.isArray(items)) { return [] }
return items
.map((item, index) => {
const clone = deepClone(item)
const currentOrder =
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
clone.orderIndex = currentOrder
if (typeof clone?.__key !== 'string' || !clone.__key) {
clone.__key = createFieldKey()
}
return clone
})
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((item, index) => ({ ...item, orderIndex: index }))
}
const createDefaultForm = (source = {}) => ({
name: source.name || '',
description: source.description || '',
category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '',
customFields: deepClone(source.customFields || []),
componentRequirements: deepClone(source.componentRequirements || []),
pieceRequirements: deepClone(source.pieceRequirements || [])
customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: withNormalizedOrder(source.pieceRequirements || [])
})
const formData = reactive(createDefaultForm(props.modelValue))