feat: improve piece structure editor UX

This commit is contained in:
Matthieu
2025-10-30 11:34:19 +01:00
parent 4c714b3647
commit 76cd3fac98

View File

@@ -1,185 +1,240 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<section class="space-y-3"> <header class="flex items-center justify-between">
<div class="flex items-center justify-between"> <h3 class="text-sm font-semibold">
<h3 class="text-sm font-semibold"> Champs personnalisés
Champs personnalisés </h3>
</h3> <button type="button" class="btn btn-outline btn-xs" @click="addField">
<button type="button" class="btn btn-outline btn-xs" @click="addField"> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" /> Ajouter
Ajouter </button>
</button> </header>
</div>
<p v-if="!localFields.length" class="text-xs text-gray-500"> <p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini. Aucun champ personnalisé n'a encore été défini.
</p> </p>
<div v-else class="space-y-2"> <ul v-else class="space-y-2" role="list">
<div <li
v-for="(field, index) in localFields" v-for="(field, index) in fields"
:key="`custom-field-${index}`" :key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2" class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
> :class="reorderClass(index)"
<div class="flex items-start justify-between gap-2"> draggable="true"
<div class="flex-1 space-y-2"> @dragstart="onDragStart(index, $event)"
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> @dragenter="onDragEnter(index)"
<input @dragover.prevent="onDragEnter(index)"
v-model="field.name" @drop.prevent="onDrop(index)"
type="text" @dragend="onDragEnd"
class="input input-bordered input-xs" >
placeholder="Nom du champ" <div class="flex items-start gap-3">
> <button
<select v-model="field.type" class="select select-bordered select-xs"> type="button"
<option value="text"> class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
Texte title="Réordonner"
</option> draggable="false"
<option value="number"> >
Nombre <IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</option> </button>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs"> <div class="flex-1 space-y-2">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
Obligatoire <input
</div> v-model="field.name"
type="text"
<textarea class="input input-bordered input-xs"
v-if="field.type === 'select'" placeholder="Nom du champ"
v-model="field.optionsText" >
class="textarea textarea-bordered textarea-xs h-20" <select v-model="field.type" class="select select-bordered select-xs">
placeholder="Option 1&#10;Option 2" <option value="text">
/> Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div> </div>
<button
type="button" <div class="flex items-center gap-2 text-xs">
class="btn btn-error btn-xs btn-square" <input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
@click="removeField(index)" Obligatoire
> </div>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> <textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
/>
</div> </div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div> </div>
</div> </li>
</section> </ul>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { computed, reactive, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import type {
PieceModelCustomField,
PieceModelCustomFieldType,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
const props = defineProps({ defineOptions({ name: 'PieceModelStructureEditor' })
modelValue: {
type: Object,
default: () => ({ customFields: [] })
}
})
const emit = defineEmits(['update:modelValue']) type EditorField = PieceModelStructureEditorField & { uid: string }
const ensureArray = value => (Array.isArray(value) ? value : []) const props = defineProps<{
modelValue?: PieceModelStructure | null
}>()
const clone = (input, fallback = {}) => { const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void
}>()
const ensureArray = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : [])
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T>(value: T, fallback: T): T => {
try { try {
return JSON.parse(JSON.stringify(input ?? fallback)) return JSON.parse(JSON.stringify(value ?? fallback)) as T
} catch (error) { } catch {
return JSON.parse(JSON.stringify(fallback)) return JSON.parse(JSON.stringify(fallback)) as T
} }
} }
const extractRest = (structure = {}) => { const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return {} return {}
} }
return Object.fromEntries( const entries = Object.entries(structure).filter(([key]) => key !== 'customFields')
Object.entries(structure).filter(([key]) => key !== 'customFields') return safeClone(Object.fromEntries(entries), {})
)
} }
const toEditorField = (input = {}, index = 0) => ({ let uidCounter = 0
name: typeof input.name === 'string' ? input.name : '', const createUid = (): string => {
type: typeof input.type === 'string' && input.type ? input.type : 'text', if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
required: Boolean(input.required), return crypto.randomUUID()
optionsText: Array.isArray(input.options) }
? input.options.join('\n') uidCounter += 1
: typeof input.optionsText === 'string' return `piece-field-${Date.now().toString(36)}-${uidCounter}`
? input.optionsText }
: '',
orderIndex: typeof input.orderIndex === 'number' ? input.orderIndex : index,
})
const hydrateFields = (structure = {}) => const toEditorField = (
ensureArray(structure.customFields) input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid(),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index)) .map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const localState = reactive({ const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
fields: hydrateFields(props.modelValue) const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
})
const extraState = reactive({ const applyOrderIndex = (list: EditorField[]): EditorField[] =>
rest: clone(extractRest(props.modelValue)) list.map((field, index) => ({
}) ...field,
orderIndex: index,
}))
const localFields = computed({ const buildPayload = (
get: () => localState.fields, fieldsSource: EditorField[],
set: (value) => { restSource: Record<string, unknown>,
localState.fields = ensureArray(value).map(toEditorField) ): PieceModelStructure => {
} const normalizedFields = fieldsSource
}) .map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
const normalizeFields = (fields) => {
return ensureArray(fields)
.map((field, index) => {
const name = typeof field.name === 'string' ? field.name.trim() : ''
if (!name) { if (!name) {
return null return null
} }
const type = field.type || 'text' const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required) const required = Boolean(field.required)
let options const payload: PieceModelCustomField = {
if (type === 'select') { name,
const raw = typeof field.optionsText === 'string' ? field.optionsText : '' type,
const parsed = raw required,
.split(/\r?\n/) orderIndex: index,
.map(option => option.trim())
.filter(option => option.length > 0)
options = parsed.length > 0 ? parsed : undefined
} }
const normalized = { name, type, required, orderIndex: index } if (type === 'select') {
if (options) { const options = normalizeLineEndings(field.optionsText)
normalized.options = options .split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
if (options.length > 0) {
payload.options = options
}
} }
return normalized
return payload
}) })
.filter(Boolean) .filter((field): field is PieceModelCustomField => Boolean(field))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
} }
let lastEmitted = JSON.stringify({ const serializeStructure = (structure?: PieceModelStructure | null): string => {
...clone(extraState.rest, {}), return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
customFields: normalizeFields(props.modelValue?.customFields) }
})
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => { const emitUpdate = () => {
const customFields = normalizeFields(localFields.value) const payload = buildPayload(fields.value, restState.value)
const payload = {
...clone(extraState.rest, {}),
customFields
}
const serialized = JSON.stringify(payload) const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) { if (serialized !== lastEmitted) {
lastEmitted = serialized lastEmitted = serialized
@@ -187,27 +242,108 @@ const emitUpdate = () => {
} }
} }
watch(fields, emitUpdate, { deep: true })
watch( watch(
() => props.modelValue, () => props.modelValue,
(value) => { (value) => {
localFields.value = hydrateFields(value) const incomingSerialized = serializeStructure(value)
extraState.rest = clone(extractRest(value), {}) if (incomingSerialized === lastEmitted) {
lastEmitted = JSON.stringify({ return
...clone(extraState.rest, {}), }
customFields: normalizeFields(value?.customFields) restState.value = extractRest(value)
}) fields.value = hydrateFields(value)
lastEmitted = incomingSerialized
}, },
{ deep: true } { deep: true },
) )
watch(localFields, emitUpdate, { deep: true }) const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null &&
dragState.dropTargetIndex === index &&
dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid(),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => { const addField = () => {
const index = localFields.value.length const next = fields.value.slice()
localFields.value = [...localFields.value, toEditorField({}, index)] next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
} }
const removeField = (index) => { const removeField = (index: number) => {
localFields.value = localFields.value.filter((_, i) => i !== index) const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
} }
</script> </script>