feat: improve piece structure editor UX
This commit is contained in:
@@ -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 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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user