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

View File

@@ -5,7 +5,7 @@
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div <div
v-for="field in customFields" v-for="field in sortedCustomFields"
:key="field.id" :key="field.id"
class="form-control" class="form-control"
> >
@@ -81,7 +81,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({ const props = defineProps({
customFields: { customFields: {
@@ -101,6 +101,17 @@ const props = defineProps({
const emit = defineEmits(['update']) 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 // Valeurs des champs personnalisés
const fieldValues = reactive({}) const fieldValues = reactive({})

View File

@@ -456,16 +456,36 @@ const extractStructureCustomFields = (structure) => {
return Array.isArray(customFields) ? customFields : []; return Array.isArray(customFields) ? customFields : [];
}; };
function fieldKeyFromNameAndType(name, type) { function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : ''; const normalizedName =
const normalizedType = typeof type === 'string' ? type : ''; typeof name === 'string' ? name.trim().toLowerCase() : '';
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : '';
return normalizedName ? `${normalizedName}::${normalizedType}` : null; 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) { function deduplicateFieldDefinitions(definitions) {
const result = []; const result = [];
const seen = new Set(); 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') { if (!field || typeof field !== 'object') {
return; return;
} }
@@ -482,6 +502,7 @@ function deduplicateFieldDefinitions(definitions) {
if (key) { if (key) {
seen.add(key); seen.add(key);
} }
field.orderIndex = resolveOrderIndex(field);
result.push(field); result.push(field);
}); });
@@ -529,9 +550,15 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
return { return {
...field, ...field,
value: field?.value ?? '', value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
}; };
} }
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
);
return { return {
...field, ...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null, customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
@@ -542,6 +569,7 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
null, null,
customField: matchedValue.customField ?? field.customField ?? null, customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '', value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
}; };
}); });
@@ -588,22 +616,31 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
options: entry.customField?.options ?? [], options: entry.customField?.options ?? [],
value: entry.value ?? '', 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) { function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) { 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 seen = new Map();
const result = []; const result = [];
fields.forEach((field) => { const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') { if (!field || typeof field !== 'object') {
return; return;
} }
@@ -632,12 +669,14 @@ function dedupeMergedFields(fields) {
const key = fieldId || nameKey; const key = fieldId || nameKey;
if (!key) { if (!key) {
field.orderIndex = resolveOrderIndex(field);
result.push(field); result.push(field);
return; return;
} }
const existing = seen.get(key); const existing = seen.get(key);
if (!existing) { if (!existing) {
field.orderIndex = resolveOrderIndex(field);
seen.set(key, field); seen.set(key, field);
result.push(field); result.push(field);
return; return;
@@ -654,11 +693,17 @@ function dedupeMergedFields(fields) {
if (!existingHasValue && incomingHasValue) { if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field); Object.assign(existing, field);
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
);
seen.set(key, existing); seen.set(key, existing);
} }
}); });
return result; return result.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
} }
const pieceDefinitionSources = computed(() => { 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 : '', name: typeof input.name === 'string' ? input.name : '',
type: typeof input.type === 'string' && input.type ? input.type : 'text', type: typeof input.type === 'string' && input.type ? input.type : 'text',
required: Boolean(input.required), required: Boolean(input.required),
@@ -116,10 +116,14 @@ const toEditorField = (input = {}) => ({
? input.options.join('\n') ? input.options.join('\n')
: typeof input.optionsText === 'string' : typeof input.optionsText === 'string'
? input.optionsText ? 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({ const localState = reactive({
fields: hydrateFields(props.modelValue) fields: hydrateFields(props.modelValue)
@@ -138,7 +142,7 @@ const localFields = computed({
const normalizeFields = (fields) => { const normalizeFields = (fields) => {
return ensureArray(fields) return ensureArray(fields)
.map((field) => { .map((field, index) => {
const name = typeof field.name === 'string' ? field.name.trim() : '' const name = typeof field.name === 'string' ? field.name.trim() : ''
if (!name) { if (!name) {
return null return null
@@ -156,7 +160,7 @@ const normalizeFields = (fields) => {
options = parsed.length > 0 ? parsed : undefined options = parsed.length > 0 ? parsed : undefined
} }
const normalized = { name, type, required } const normalized = { name, type, required, orderIndex: index }
if (options) { if (options) {
normalized.options = options normalized.options = options
} }
@@ -199,7 +203,8 @@ watch(
watch(localFields, emitUpdate, { deep: true }) watch(localFields, emitUpdate, { deep: true })
const addField = () => { const addField = () => {
localFields.value = [...localFields.value, toEditorField()] const index = localFields.value.length
localFields.value = [...localFields.value, toEditorField({}, index)]
} }
const removeField = (index) => { const removeField = (index) => {

View File

@@ -79,9 +79,27 @@
<div <div
v-for="(field, index) in node.customFields" v-for="(field, index) in node.customFields"
:key="`field-${index}`" :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-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="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input <input
@@ -506,20 +524,97 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
piece.typePieceLabel = formatPieceTypeOption(option) 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 = () => { const addCustomField = () => {
ensureArray('customFields') ensureArray('customFields')
const nextIndex = Array.isArray(props.node.customFields)
? props.node.customFields.length
: 0
props.node.customFields.push({ props.node.customFields.push({
name: '', name: '',
type: 'text', type: 'text',
required: false, required: false,
optionsText: '', optionsText: '',
options: [], options: [],
orderIndex: nextIndex,
}) })
reindexCustomFields()
} }
const removeCustomField = (index: number) => { const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1) props.node.customFields.splice(index, 1)
reindexCustomFields()
} }
const addPiece = () => { const addPiece = () => {
@@ -723,6 +818,22 @@ watch(
{ deep: true } { 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( watch(
() => [props.lockedTypeLabel, props.lockType], () => [props.lockedTypeLabel, props.lockType],
() => { () => {

View File

@@ -24,11 +24,26 @@
<div v-if="expanded" class="space-y-4"> <div v-if="expanded" class="space-y-4">
<div <div
v-for="(field, fieldIndex) in fields" v-for="(field, fieldIndex) in fields"
:key="fieldIndex" :key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
class="border border-gray-200 rounded-lg p-4 bg-gray-50" 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 justify-between mb-3">
<div class="flex items-center 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="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs p-1" 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 IconLucideListChecks from '~icons/lucide/list-checks'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -183,8 +199,57 @@ const fields = computed({
set: value => emit('update:modelValue', value) set: value => emit('update:modelValue', value)
}) })
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const expanded = ref(false) const expanded = ref(false)
const expandedFields = ref([]) 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( watch(
() => props.expandAllTrigger, () => props.expandAllTrigger,
@@ -223,26 +288,25 @@ const toggleField = (index) => {
} }
const addField = () => { const addField = () => {
fields.value = [ const next = Array.isArray(fields.value) ? fields.value.slice() : []
...fields.value, next.push(createEmptyField())
{ fields.value = applyOrderIndex(next)
name: '',
type: '',
required: false,
optionsText: ''
}
]
expandedFields.value.push(true) expandedFields.value.push(true)
expanded.value = true expanded.value = true
} }
const removeField = (index) => { 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) expandedFields.value.splice(index, 1)
} }
const updateField = (index, patch) => { 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) => { const updateOptions = (index, value) => {
@@ -250,4 +314,43 @@ const updateOptions = (index, value) => {
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') 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> </script>

View File

@@ -54,14 +54,33 @@ const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = value => JSON.parse(JSON.stringify(value)) 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 = {}) => ({ const createDefaultForm = (source = {}) => ({
name: source.name || '', name: source.name || '',
description: source.description || '', description: source.description || '',
category: source.category || '', category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '', maintenanceFrequency: source.maintenanceFrequency || '',
customFields: deepClone(source.customFields || []), customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: deepClone(source.componentRequirements || []), componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: deepClone(source.pieceRequirements || []) pieceRequirements: withNormalizedOrder(source.pieceRequirements || [])
}) })
const formData = reactive(createDefaultForm(props.modelValue)) const formData = reactive(createDefaultForm(props.modelValue))

View File

@@ -424,6 +424,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const route = useRoute() const route = useRoute()
@@ -756,6 +757,7 @@ const buildCustomFieldInputs = (
...definition, ...definition,
customFieldId: definition.customFieldId || definition.id, customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null, customFieldValueId: null,
orderIndex: definition.orderIndex,
} }
} }
@@ -765,8 +767,14 @@ const buildCustomFieldInputs = (
customFieldId: matched.customField?.id || definition.customFieldId || definition.id, customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null, customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue), value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
} }
}) }).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved return resolved
} }
@@ -780,11 +788,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -802,7 +811,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
const customFieldValueId = typeof rawField.customFieldValueId === 'string' const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {

View File

@@ -841,6 +841,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const fieldKey = (field: CustomFieldInput, index: number) => const fieldKey = (field: CustomFieldInput, index: number) =>
@@ -852,11 +853,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -874,7 +876,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
const customFieldValueId = typeof rawField.customFieldValueId === 'string' const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {

View File

@@ -139,12 +139,15 @@ const parseOptions = (field = {}) => {
const normalizeCustomFields = (fields = []) => const normalizeCustomFields = (fields = []) =>
fields fields
.filter(field => field?.name && field.name.trim() !== '') .filter(field => field?.name && field.name.trim() !== '')
.map(field => ({ .map((field, index) => ({
name: field.name, name: field.name,
type: field.type || '', type: field.type || '',
required: !!field.required, required: !!field.required,
options: parseOptions(field) options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
})) }))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => { const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) { if (value === '' || value === undefined || value === null) {

View File

@@ -1853,6 +1853,12 @@ const visibleMachineCustomFields = computed(() => {
const summarizeCustomFields = (fields = []) => { const summarizeCustomFields = (fields = []) => {
const seen = new Set() const seen = new Set()
return fields return fields
.slice()
.sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
.filter(shouldDisplayCustomField) .filter(shouldDisplayCustomField)
.filter((field) => { .filter((field) => {
const key = field.customFieldId || field.id || field.name const key = field.customFieldId || field.id || field.name
@@ -2013,11 +2019,12 @@ const normalizeExistingCustomFieldDefinitions = (fields) => {
return [] return []
} }
return fields return fields
.map((field) => normalizeCustomFieldDefinitionEntry(field)) .map((field, index) => normalizeCustomFieldDefinitionEntry(field, index))
.filter((definition) => definition !== null) .filter((definition) => definition !== null)
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
} }
const normalizeCustomFieldDefinitionEntry = (definition = {}) => { const normalizeCustomFieldDefinitionEntry = (definition = {}, fallbackIndex = 0) => {
const name = extractDefinitionName(definition) const name = extractDefinitionName(definition)
if (!name) { if (!name) {
return null return null
@@ -2028,6 +2035,7 @@ const normalizeCustomFieldDefinitionEntry = (definition = {}) => {
const defaultValue = extractDefinitionDefaultValue(definition) const defaultValue = extractDefinitionDefaultValue(definition)
const id = typeof definition?.id === 'string' ? definition.id : undefined const id = typeof definition?.id === 'string' ? definition.id : undefined
const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id
const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex
return { return {
id, id,
customFieldId, customFieldId,
@@ -2037,6 +2045,7 @@ const normalizeCustomFieldDefinitionEntry = (definition = {}) => {
options, options,
defaultValue, defaultValue,
readOnly: !!definition?.readOnly, readOnly: !!definition?.readOnly,
orderIndex,
} }
} }

View File

@@ -383,6 +383,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const route = useRoute() const route = useRoute()
@@ -706,6 +707,7 @@ const buildCustomFieldInputs = (
...definition, ...definition,
customFieldId: definition.customFieldId || definition.id, customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null, customFieldValueId: null,
orderIndex: definition.orderIndex,
} }
} }
@@ -715,8 +717,14 @@ const buildCustomFieldInputs = (
customFieldId: matched.customField?.id || definition.customFieldId || definition.id, customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null, customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue), value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
} }
}) }).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved return resolved
} }
@@ -730,11 +738,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -755,7 +764,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {

View File

@@ -467,6 +467,7 @@ interface CustomFieldInput {
value: string value: string
customFieldId: string | null customFieldId: string | null
customFieldValueId: string | null customFieldValueId: string | null
orderIndex: number
} }
const fieldKey = (field: CustomFieldInput, index: number) => const fieldKey = (field: CustomFieldInput, index: number) =>
@@ -478,11 +479,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
} }
const fields = Array.isArray(structure.customFields) ? structure.customFields : [] const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields return fields
.map((field) => normalizeCustomField(field)) .map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null) .filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
} }
const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') { if (!rawField || typeof rawField !== 'object') {
return null return null
} }
@@ -503,7 +505,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
? rawField.customFieldValueId ? rawField.customFieldValueId
: null : null
return { id, name, type, required, options, value, customFieldId, customFieldValueId } const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
} }
const resolveFieldName = (field: any): string => { const resolveFieldName = (field: any): string => {

View File

@@ -92,12 +92,15 @@ const parseOptions = (field = {}) => {
const normalizeCustomFields = (fields = []) => const normalizeCustomFields = (fields = []) =>
fields fields
.filter(field => field?.name && field.name.trim() !== '') .filter(field => field?.name && field.name.trim() !== '')
.map(field => ({ .map((field, index) => ({
name: field.name, name: field.name,
type: field.type || '', type: field.type || '',
required: !!field.required, required: !!field.required,
options: parseOptions(field) options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
})) }))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => { const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) { if (value === '' || value === undefined || value === null) {

View File

@@ -94,7 +94,7 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
} }
return fields return fields
.map((field) => { .map((field, index) => {
const rawName = const rawName =
typeof field?.name === 'string' typeof field?.name === 'string'
? field.name ? field.name
@@ -173,6 +173,8 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (customFieldId) { if (customFieldId) {
result.customFieldId = customFieldId result.customFieldId = customFieldId
} }
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result return result
}) })
.filter((field): field is ComponentModelCustomField => !!field) .filter((field): field is ComponentModelCustomField => !!field)
@@ -448,7 +450,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
return [] return []
} }
return fields.map((field) => { return fields.map((field, index) => {
const valueObject = extractFieldValueObject(field) const valueObject = extractFieldValueObject(field)
const name = typeof field?.name === 'string' const name = typeof field?.name === 'string'
? field.name ? field.name
@@ -513,6 +515,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
const id = typeof field?.id === 'string' ? field.id : undefined const id = typeof field?.id === 'string' ? field.id : undefined
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
return { return {
name, name,
@@ -523,6 +526,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
defaultValue, defaultValue,
id, id,
customFieldId, customFieldId,
orderIndex,
} }
}) })
} }
@@ -580,7 +584,7 @@ const mapComponentCustomFields = (fields: any[]) => {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
return [] return []
} }
return hydrateCustomFields(fields).map((field) => { return hydrateCustomFields(fields).map((field, index) => {
const defaultValue = const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== '' field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? field.defaultValue ? field.defaultValue
@@ -597,6 +601,7 @@ const mapComponentCustomFields = (fields: any[]) => {
typeof (field as any)?.customFieldId === 'string' typeof (field as any)?.customFieldId === 'string'
? (field as any).customFieldId ? (field as any).customFieldId
: undefined, : undefined,
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
} }
}) })
} }
@@ -772,7 +777,7 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
} }
return fields return fields
.map((field) => { .map((field, index) => {
const name = typeof field?.name === 'string' ? field.name.trim() : '' const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) { if (!name) {
return null return null
@@ -799,6 +804,8 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (options) { if (options) {
result.options = options result.options = options
} }
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result return result
}) })
.filter((field): field is PieceModelCustomField => !!field) .filter((field): field is PieceModelCustomField => !!field)
@@ -819,7 +826,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
return [] return []
} }
return fields.map((field) => ({ return fields.map((field, index) => ({
name: field?.name ?? '', name: field?.name ?? '',
type: field?.type ?? 'text', type: field?.type ?? 'text',
required: !!field?.required, required: !!field?.required,
@@ -829,6 +836,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
: Array.isArray(field?.options) : Array.isArray(field?.options)
? field.options.join('\n') ? field.options.join('\n')
: '', : '',
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
})) }))
} }

View File

@@ -9,6 +9,7 @@ export interface ComponentModelCustomField {
optionsText?: string optionsText?: string
id?: string id?: string
customFieldId?: string customFieldId?: string
orderIndex?: number
} }
export interface ComponentModelPiece { export interface ComponentModelPiece {
@@ -40,6 +41,7 @@ export interface PieceModelCustomField {
type: PieceModelCustomFieldType type: PieceModelCustomFieldType
required: boolean required: boolean
options?: string[] options?: string[]
orderIndex?: number
} }
export interface PieceModelStructure { export interface PieceModelStructure {